@testkase/test-management-mcp 0.0.0-stage → 2.4.0

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.
package/build/index.js ADDED
@@ -0,0 +1,1678 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import axios from "axios";
6
+ const config = {
7
+ apiBaseUrl: (process.env.TESTKASE_API_BASE_URL || "https://api.testkase.com").replace(/\/+$/, ""),
8
+ patToken: process.env.TESTKASE_PAT_TOKEN,
9
+ // Only needed when the PAT user belongs to more than one organization
10
+ organizationId: process.env.TESTKASE_ORGANIZATION_ID
11
+ ? Number(process.env.TESTKASE_ORGANIZATION_ID)
12
+ : undefined,
13
+ appUrl: process.env.TESTKASE_APP_URL?.replace(/\/+$/, ""),
14
+ };
15
+ const VERSION = "2.4.0";
16
+ const REQUEST_TIMEOUT_MS = 60_000;
17
+ // ─── Frontend URL helper ─────────────────────────────────────────────────────
18
+ // Test management lives on its own subdomain
19
+ const KNOWN_APP_HOSTS = {
20
+ "api.testkase.com": "https://test-management.testkase.com",
21
+ "apiqa.testkase.com": "https://test-management-qa.testkase.com",
22
+ };
23
+ function getFrontendBaseUrl() {
24
+ if (config.appUrl)
25
+ return config.appUrl;
26
+ const url = new URL(config.apiBaseUrl);
27
+ if (KNOWN_APP_HOSTS[url.hostname])
28
+ return KNOWN_APP_HOSTS[url.hostname];
29
+ url.hostname = url.hostname.replace(/^api\.?/, "");
30
+ return url.origin;
31
+ }
32
+ // ─── Auth helpers (reused from v1) ───────────────────────────────────────────
33
+ function validateAuth() {
34
+ if (!config.patToken) {
35
+ throw new Error("Authentication required. Please set TESTKASE_PAT_TOKEN environment variable. " +
36
+ "Generate a Personal Access Token (PAT) from your TestKase account settings.");
37
+ }
38
+ if (!config.patToken.startsWith("xyz_")) {
39
+ throw new Error("Invalid PAT token format. TestKase PAT tokens must start with 'xyz_' prefix. " +
40
+ "Please generate a valid PAT from your TestKase account settings.");
41
+ }
42
+ }
43
+ function getApiHeaders() {
44
+ return {
45
+ "Content-Type": "application/json",
46
+ Authorization: `Bearer ${config.patToken}`,
47
+ };
48
+ }
49
+ // ─── Cached org ID (auto-resolved from PAT, never a tool param) ─────────────
50
+ let cachedOrganizationId = null;
51
+ async function getOrganizationId() {
52
+ if (config.organizationId && !isNaN(config.organizationId))
53
+ return config.organizationId;
54
+ if (cachedOrganizationId !== null)
55
+ return cachedOrganizationId;
56
+ validateAuth();
57
+ try {
58
+ const response = await axios.get(`${config.apiBaseUrl}/api/v1/organizations/my-organization`, { headers: getApiHeaders(), timeout: REQUEST_TIMEOUT_MS });
59
+ const orgData = response.data?.data || response.data;
60
+ if (orgData?.organization_id) {
61
+ cachedOrganizationId = orgData.organization_id;
62
+ return cachedOrganizationId;
63
+ }
64
+ throw new Error("Could not determine organization from PAT token");
65
+ }
66
+ catch (error) {
67
+ if (error instanceof Error && error.message.includes("Could not determine"))
68
+ throw error;
69
+ const status = axios.isAxiosError(error) ? error.response?.status : undefined;
70
+ throw new Error(status === 401 || status === 403
71
+ ? `Failed to fetch organization (status ${status}). Your PAT token is invalid, expired or revoked.`
72
+ : `Failed to fetch organization${status ? ` (status ${status})` : ""}. Check TESTKASE_API_BASE_URL and your network.`);
73
+ }
74
+ }
75
+ // ─── API call helper ─────────────────────────────────────────────────────────
76
+ async function callApi(method, path, options = {}) {
77
+ validateAuth();
78
+ const orgId = options.skipOrgId ? undefined : await getOrganizationId();
79
+ const queryParams = new URLSearchParams();
80
+ if (orgId)
81
+ queryParams.append("organizationId", orgId.toString());
82
+ if (options.query) {
83
+ for (const [key, val] of Object.entries(options.query)) {
84
+ if (val !== undefined && val !== null && val !== "") {
85
+ queryParams.append(key, String(val));
86
+ }
87
+ }
88
+ }
89
+ const qs = queryParams.toString();
90
+ const url = `${config.apiBaseUrl}/api/v1/${path}${qs ? `?${qs}` : ""}`;
91
+ const response = await axios({
92
+ method,
93
+ url,
94
+ headers: getApiHeaders(),
95
+ timeout: REQUEST_TIMEOUT_MS,
96
+ ...(options.body !== undefined && { data: options.body }),
97
+ });
98
+ // Some endpoints report "not found" as HTTP 200 with status "error"
99
+ const data = response.data;
100
+ if (data && typeof data === "object" && data.status === "error") {
101
+ const detail = data.message || data.success || data.error || "Request failed";
102
+ throw new Error(`API returned an error: ${String(detail).slice(0, 500)}`);
103
+ }
104
+ return data;
105
+ }
106
+ // ─── Error handler (reused) ──────────────────────────────────────────────────
107
+ function handleApiError(error) {
108
+ if (axios.isAxiosError(error)) {
109
+ const e = error;
110
+ let msg = `API request failed: ${e.message}`;
111
+ if (e.response) {
112
+ msg += `\nStatus: ${e.response.status}`;
113
+ if (e.response.status === 401) {
114
+ cachedOrganizationId = null;
115
+ }
116
+ if (e.response.data) {
117
+ const d = e.response.data;
118
+ const safeMsg = d.message || d.error || (typeof d === "string" ? d : undefined);
119
+ if (safeMsg)
120
+ msg += `\nDetails: ${String(safeMsg).slice(0, 500)}`;
121
+ }
122
+ }
123
+ return new Error(msg);
124
+ }
125
+ return error;
126
+ }
127
+ // ─── JSON response helper ────────────────────────────────────────────────────
128
+ function jsonResponse(data) {
129
+ return {
130
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
131
+ };
132
+ }
133
+ function safeJsonParse(raw, fieldName) {
134
+ if (typeof raw !== "string")
135
+ return raw;
136
+ try {
137
+ return JSON.parse(raw);
138
+ }
139
+ catch {
140
+ throw new Error(`Invalid JSON in ${fieldName}. Ensure the value is valid JSON.`);
141
+ }
142
+ }
143
+ function validateId(id, fieldName) {
144
+ const str = String(id);
145
+ if (!/^[a-zA-Z0-9_\-]+$/.test(str)) {
146
+ throw new Error(`Invalid ${fieldName}: contains disallowed characters`);
147
+ }
148
+ return str;
149
+ }
150
+ function toIdList(raw) {
151
+ return Array.isArray(raw) ? raw.join(",") : String(raw);
152
+ }
153
+ function toStringArray(raw) {
154
+ if (Array.isArray(raw))
155
+ return raw.map((v) => String(v).trim()).filter(Boolean);
156
+ return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
157
+ }
158
+ // Normalise one test step; the backend requires `description` to be a string
159
+ function normalizeStep(s) {
160
+ if (typeof s === "string")
161
+ return { description: s };
162
+ const step = {
163
+ description: s?.description ?? s?.step ?? s?.action ?? "",
164
+ test_data: s?.test_data ?? s?.testData,
165
+ expected_result: s?.expected_result ?? s?.expectedResult ?? s?.expected,
166
+ };
167
+ if (!step.description && !step.test_data && !step.expected_result) {
168
+ throw new Error("Each test step needs at least a description, test_data or expected_result");
169
+ }
170
+ return step;
171
+ }
172
+ function parseSteps(raw) {
173
+ if (raw === undefined || raw === null || raw === "")
174
+ return null;
175
+ const parsed = safeJsonParse(raw, "test_steps");
176
+ if (!Array.isArray(parsed))
177
+ throw new Error("test_steps must be a JSON array");
178
+ return parsed.map(normalizeStep);
179
+ }
180
+ // Steps are added one call each; collect failures instead of aborting
181
+ async function addSteps(projectId, testcaseId, steps) {
182
+ let created = 0;
183
+ const errors = [];
184
+ for (const [i, step] of steps.entries()) {
185
+ try {
186
+ await callApi("post", `projects/testcases/teststeps/${projectId}/${testcaseId}`, { body: step });
187
+ created++;
188
+ }
189
+ catch (error) {
190
+ const err = axios.isAxiosError(error) ? handleApiError(error) : error;
191
+ errors.push(`step ${i + 1}: ${err.message}`);
192
+ }
193
+ }
194
+ return { created, errors };
195
+ }
196
+ // The list endpoints only accept array values inside the filters JSON
197
+ function buildFilters(raw, extra = {}) {
198
+ const parsed = raw ? safeJsonParse(raw, "filters") : {};
199
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
200
+ throw new Error("filters must be a JSON object");
201
+ }
202
+ const merged = { ...parsed };
203
+ for (const [k, v] of Object.entries(extra)) {
204
+ if (v !== undefined && v !== null && v !== "")
205
+ merged[k] = v;
206
+ }
207
+ for (const k of Object.keys(merged)) {
208
+ if (k === "custom_fields" || k.startsWith("custom_fields."))
209
+ continue;
210
+ if (!Array.isArray(merged[k]))
211
+ merged[k] = [merged[k]];
212
+ }
213
+ return Object.keys(merged).length ? JSON.stringify(merged) : undefined;
214
+ }
215
+ // Builds the create/bulk-create payload from tool args (shared by both actions)
216
+ async function buildTestcasePayload(projectId, tc) {
217
+ const data = { title: tc.title ?? tc.name };
218
+ const summary = tc.summary ?? tc.description;
219
+ if (summary)
220
+ data.summary = summary;
221
+ for (const f of ["priority", "status", "automated", "platform"]) {
222
+ if (tc[f])
223
+ data[f] = await coerceFieldValue(projectId, f, tc[f]);
224
+ }
225
+ const rawFolderId = tc.folder_id ?? tc.folderId;
226
+ if (rawFolderId !== undefined && rawFolderId !== null && rawFolderId !== "") {
227
+ const num = Number(rawFolderId);
228
+ if (isNaN(num))
229
+ throw new Error("folder_id must be a numeric folder ID");
230
+ data.folder_id = num;
231
+ }
232
+ if (tc.labels)
233
+ data.labels = toStringArray(tc.labels);
234
+ if (tc.features)
235
+ data.features = toStringArray(tc.features);
236
+ if (tc.environment)
237
+ data.environment = toStringArray(tc.environment);
238
+ for (const f of ["precondition", "test_data", "expected_result"]) {
239
+ if (tc[f])
240
+ data[f] = tc[f];
241
+ }
242
+ const ownerId = tc.owner_id ?? tc.ownerId;
243
+ if (ownerId) {
244
+ const num = Number(ownerId);
245
+ data.owner_id = isNaN(num) ? ownerId : num;
246
+ }
247
+ const customRaw = tc.custom_fields ?? tc.customFields;
248
+ if (customRaw) {
249
+ const parsed = safeJsonParse(customRaw, "custom_fields");
250
+ for (const key of Object.keys(parsed)) {
251
+ if (!Array.isArray(parsed[key]))
252
+ parsed[key] = [parsed[key]];
253
+ }
254
+ data.custom_fields = parsed;
255
+ }
256
+ return data;
257
+ }
258
+ function jsonResponseWithHints(data, hints) {
259
+ const json = JSON.stringify(data, null, 2);
260
+ const hintsBlock = hints.length > 0
261
+ ? `\n\n---\nHINTS:\n${hints.map((h) => `- ${h}`).join("\n")}`
262
+ : "";
263
+ return {
264
+ content: [{ type: "text", text: json + hintsBlock }],
265
+ };
266
+ }
267
+ // ─── Error enrichment ───────────────────────────────────────────────────────
268
+ const ERROR_HINTS = [
269
+ { pattern: /Invalid value.*for field/i,
270
+ hint: "RECOVERY: Call get_project_structure(projectId, include='field_options') to discover allowed values for this field, then retry." },
271
+ { pattern: /must be a number/i,
272
+ hint: "RECOVERY: This field requires a numeric ID. Call get_project_structure(projectId) to find valid IDs." },
273
+ { pattern: /folder_id/i,
274
+ hint: "RECOVERY: folder_id must be a numeric folder ID. Call get_project_structure(projectId, include='folders') to list folders with their IDs." },
275
+ { pattern: /status.*(401|unauthorized)/i,
276
+ hint: "RECOVERY: PAT token may be expired. User needs to regenerate it from TestKase > API Keys." },
277
+ { pattern: /Status: 403/i,
278
+ hint: "RECOVERY: The user's project role does not allow this action (e.g. only owners and project admins can delete folders). If the project belongs to another organization, set TESTKASE_ORGANIZATION_ID." },
279
+ { pattern: /priority/i,
280
+ hint: "RECOVERY: Priority values are project-specific. Call get_project_structure(projectId, include='field_options') to discover allowed values." },
281
+ ];
282
+ function enrichErrorMessage(errorMsg) {
283
+ for (const { pattern, hint } of ERROR_HINTS) {
284
+ if (pattern.test(errorMsg)) {
285
+ return `${errorMsg}\n\n${hint}`;
286
+ }
287
+ }
288
+ return errorMsg;
289
+ }
290
+ // ─── Field options cache (5-min TTL) ────────────────────────────────────────
291
+ const fieldOptionsCache = new Map();
292
+ const FIELD_OPTIONS_TTL = 5 * 60 * 1000; // 5 minutes
293
+ async function getFieldOptions(projectId) {
294
+ const cached = fieldOptionsCache.get(projectId);
295
+ if (cached && Date.now() - cached.ts < FIELD_OPTIONS_TTL) {
296
+ return cached.data;
297
+ }
298
+ try {
299
+ const response = await callApi("get", `projects/fields/options/${projectId}`);
300
+ const options = response?.data || response || [];
301
+ fieldOptionsCache.set(projectId, { data: options, ts: Date.now() });
302
+ return options;
303
+ }
304
+ catch {
305
+ return cached?.data || [];
306
+ }
307
+ }
308
+ async function coerceFieldValue(projectId, fieldName, rawValue) {
309
+ if (rawValue === undefined || rawValue === null)
310
+ return rawValue;
311
+ const options = await getFieldOptions(projectId);
312
+ const fieldDef = options.find((f) => f.fieldName?.toLowerCase() === fieldName.toLowerCase());
313
+ if (!fieldDef?.values || !Array.isArray(fieldDef.values))
314
+ return rawValue;
315
+ const strValue = String(rawValue);
316
+ // Exact match
317
+ if (fieldDef.values.includes(strValue))
318
+ return strValue;
319
+ // Case-insensitive match
320
+ const match = fieldDef.values.find((v) => v.toLowerCase() === strValue.toLowerCase());
321
+ if (match)
322
+ return match;
323
+ // No match — return as-is, let API validate
324
+ return rawValue;
325
+ }
326
+ // ─── Param normalizer (handles camelCase / snake_case) ───────────────────────
327
+ function p(args, ...names) {
328
+ for (const n of names) {
329
+ if (args[n] !== undefined)
330
+ return args[n];
331
+ }
332
+ return undefined;
333
+ }
334
+ function requireArg(args, name, aliases = []) {
335
+ const value = p(args, name, ...aliases);
336
+ if (value === undefined || value === null || value === "") {
337
+ throw new Error(`${name} is required for this action`);
338
+ }
339
+ return value;
340
+ }
341
+ // ═══════════════════════════════════════════════════════════════════════════════
342
+ // TOOL DEFINITIONS (11 tools)
343
+ // ═══════════════════════════════════════════════════════════════════════════════
344
+ const LIST_PROJECTS_TOOL = {
345
+ name: "list_projects",
346
+ description: "List all projects the authenticated user has access to. " +
347
+ "Use this first to find a projectId, then pass it to other tools.",
348
+ annotations: { readOnlyHint: true },
349
+ inputSchema: {
350
+ type: "object",
351
+ properties: {
352
+ page: { type: "number", description: "Page number (default: 1)" },
353
+ limit: { type: "number", description: "Items per page (default: 12)" },
354
+ },
355
+ required: [],
356
+ },
357
+ };
358
+ const GET_PROJECT_STRUCTURE_TOOL = {
359
+ name: "get_project_structure",
360
+ description: "Get the structure of a project: folders, labels, team members, and field options (allowed values for priority, status, etc.). " +
361
+ "IMPORTANT: Call this before create/update to discover allowed field values, which are project-specific.",
362
+ annotations: { readOnlyHint: true },
363
+ inputSchema: {
364
+ type: "object",
365
+ properties: {
366
+ projectId: { type: "string", description: "Project ID (e.g. PRJ-1001)" },
367
+ section: {
368
+ type: "string",
369
+ enum: ["TESTCASE", "TEST_CYCLE", "TEST_PLAN"],
370
+ description: "Folder section (default: TESTCASE)",
371
+ },
372
+ include: {
373
+ type: "string",
374
+ description: "Comma-separated list of what to include: folders,labels,members,field_options (default: all)",
375
+ },
376
+ },
377
+ required: ["projectId"],
378
+ },
379
+ };
380
+ const SEARCH_TESTCASES_TOOL = {
381
+ name: "search_testcases",
382
+ description: "Search and list test cases in a project. Supports text search, folder filter, filters, sorting, and pagination.",
383
+ annotations: { readOnlyHint: true },
384
+ inputSchema: {
385
+ type: "object",
386
+ properties: {
387
+ projectId: { type: "string", description: "Project ID" },
388
+ search: { type: "string", description: "Search text (ID, title, summary, field values)" },
389
+ folder_id: {
390
+ type: "number",
391
+ description: "Only test cases in this folder (sub-folders included). Use 0 for test cases with no folder.",
392
+ },
393
+ filters: {
394
+ type: "string",
395
+ description: 'JSON object of filters; every value is a list. Keys are field names (use the field_options names). ' +
396
+ 'Example: {"status":["Active"],"priority":["High","Critical"]}. Also supports "exclude_status".',
397
+ },
398
+ page: { type: "number", description: "Page number (default: 1)" },
399
+ limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
400
+ sortBy: { type: "string", description: "Field to sort by (e.g. created_at, title)" },
401
+ sortOrder: { type: "string", enum: ["asc", "desc"], description: "Sort direction" },
402
+ },
403
+ required: ["projectId"],
404
+ },
405
+ };
406
+ const GET_TESTCASE_TOOL = {
407
+ name: "get_testcase",
408
+ description: "Get full details of a single test case including test steps (with step ids) and attachments.",
409
+ annotations: { readOnlyHint: true },
410
+ inputSchema: {
411
+ type: "object",
412
+ properties: {
413
+ projectId: { type: "string", description: "Project ID" },
414
+ testcaseId: { type: "string", description: "Test case ID (e.g. TEST-123)" },
415
+ },
416
+ required: ["projectId", "testcaseId"],
417
+ },
418
+ };
419
+ const TESTCASE_FIELDS = "title, summary, precondition, priority, status, automated, test_data, expected_result, labels, features, platform, environment, custom_fields, owner_id, folder_id";
420
+ const MANAGE_TESTCASE_TOOL = {
421
+ name: "manage_testcase",
422
+ description: "IMPORTANT: Before creating/updating, call get_project_structure(projectId) to discover folder IDs and allowed field values.\n\n" +
423
+ "Create, update or delete test cases and their test steps.\n\n" +
424
+ "ACTIONS:\n" +
425
+ " create - Create a single test case. Params: title (required), test_steps, and any of: " + TESTCASE_FIELDS + "\n" +
426
+ " create_bulk - Create up to 100 test cases in one go. Params: testcases (JSON array; each item takes the same fields as create, including test_steps)\n" +
427
+ " update - Update one field for test case(s). Params: ids, field, value (all required)\n" +
428
+ " delete - PERMANENTLY delete test case(s) with their steps, history and links. Params: ids (required)\n" +
429
+ " add_steps - Append steps to a test case. Params: testcaseId, test_steps\n" +
430
+ " update_step - Change one step. Params: testcaseId, step_id, and description/test_data/expected_result\n" +
431
+ " delete_steps - Delete steps. Params: testcaseId, step_ids (comma-separated)\n" +
432
+ " reorder_steps - Set the step order. Params: testcaseId, step_ids (all step ids, in the new order)\n" +
433
+ "Step ids come from get_testcase.\n\n" +
434
+ "VALID UPDATE FIELDS: " + TESTCASE_FIELDS + "\n\n" +
435
+ "EXAMPLES:\n" +
436
+ " Create: { action: 'create', title: 'Verify login', test_steps: '[{\"description\":\"Open login page\",\"expected_result\":\"Form shown\"}]' }\n" +
437
+ " Update: { action: 'update', ids: 'TEST-1', field: 'priority', value: '<value from get_project_structure>' }\n" +
438
+ " Delete: { action: 'delete', ids: 'TEST-1,TEST-2' }",
439
+ annotations: { destructiveHint: true },
440
+ inputSchema: {
441
+ type: "object",
442
+ properties: {
443
+ projectId: { type: "string", description: "Project ID" },
444
+ action: {
445
+ type: "string",
446
+ enum: ["create", "create_bulk", "update", "delete", "add_steps", "update_step", "delete_steps", "reorder_steps"],
447
+ description: "Action to perform",
448
+ },
449
+ // create params
450
+ title: { type: "string", description: "[create] Test case title" },
451
+ summary: { type: "string", description: "[create] Description/summary" },
452
+ priority: {
453
+ type: "string",
454
+ description: "[create] Priority. Values are project-specific — call get_project_structure(projectId, include='field_options') first to discover allowed values.",
455
+ },
456
+ folder_id: { type: "number", description: "[create] Folder ID to place the test case in" },
457
+ labels: {
458
+ type: "string",
459
+ description: "[create] Comma-separated label names",
460
+ },
461
+ test_steps: {
462
+ type: "string",
463
+ description: '[create/add_steps] JSON array of test steps: [{"description":"...","test_data":"...","expected_result":"..."}]. Every step needs a description.',
464
+ },
465
+ precondition: { type: "string", description: "[create] Preconditions for the test case" },
466
+ status: {
467
+ type: "string",
468
+ description: "[create] Status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
469
+ },
470
+ automated: {
471
+ type: "string",
472
+ description: "[create] Automation status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
473
+ },
474
+ test_data: { type: "string", description: "[create/update_step] Test data" },
475
+ expected_result: { type: "string", description: "[create/update_step] Expected result" },
476
+ platform: {
477
+ type: "string",
478
+ description: "[create] Platform. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
479
+ },
480
+ environment: { type: "string", description: "[create] Comma-separated environment values" },
481
+ features: { type: "string", description: "[create] Comma-separated feature/component names" },
482
+ owner_id: { type: "string", description: "[create] Owner user ID (a project member)" },
483
+ custom_fields: {
484
+ type: "string",
485
+ description: '[create] JSON object of custom field values (e.g. {"custom1": "value", "custom2": ["val1","val2"]})',
486
+ },
487
+ // create_bulk params
488
+ testcases: {
489
+ type: "string",
490
+ description: "[create_bulk] JSON array of test cases. Each item takes the create fields, e.g. " +
491
+ '[{"title":"...","folder_id":12,"priority":"High","test_steps":[{"description":"...","expected_result":"..."}]}]',
492
+ },
493
+ // update/delete params
494
+ ids: {
495
+ type: "string",
496
+ description: "[update/delete] Test case IDs, comma-separated (e.g. 'TEST-1' or 'TEST-1,TEST-2')",
497
+ },
498
+ field: {
499
+ type: "string",
500
+ enum: TESTCASE_FIELDS.split(", "),
501
+ description: "[update] Field to update. For priority/status/automated/platform/environment, call get_project_structure(include='field_options') to get allowed values.",
502
+ },
503
+ value: { type: "string", description: "[update] New value. String for most fields. For labels/features/environment: comma-separated. For custom_fields: JSON object (e.g. {\"fieldName\": \"value\"})" },
504
+ // step params
505
+ testcaseId: { type: "string", description: "[add_steps/update_step/delete_steps/reorder_steps] Test case ID (e.g. TEST-123)" },
506
+ step_id: { type: "number", description: "[update_step] Step ID to change" },
507
+ description: { type: "string", description: "[update_step] Step description" },
508
+ step_ids: { type: "string", description: "[delete_steps/reorder_steps] Comma-separated step IDs" },
509
+ },
510
+ required: ["projectId", "action"],
511
+ },
512
+ };
513
+ const MANAGE_FOLDER_TOOL = {
514
+ name: "manage_folder",
515
+ description: "Create, rename, move, or delete folders. Works across all sections (TESTCASE, TEST_CYCLE, TEST_PLAN).\n\n" +
516
+ "ACTIONS:\n" +
517
+ " create - Create a folder. Params: name (required), section, parentId\n" +
518
+ " rename - Rename a folder. Params: folderId (required), name (required)\n" +
519
+ " move - Move a folder. Params: folderId (required), parentId (omit or null for root)\n" +
520
+ " delete - PERMANENTLY delete a folder, all its sub-folders AND every test case / cycle / plan inside them. " +
521
+ "Only owners and project admins can do this. Confirm with the user first. Params: folderId (required)",
522
+ annotations: { destructiveHint: true },
523
+ inputSchema: {
524
+ type: "object",
525
+ properties: {
526
+ projectId: { type: "string", description: "Project ID" },
527
+ action: {
528
+ type: "string",
529
+ enum: ["create", "rename", "move", "delete"],
530
+ description: "Action to perform",
531
+ },
532
+ section: {
533
+ type: "string",
534
+ enum: ["TESTCASE", "TEST_CYCLE", "TEST_PLAN"],
535
+ description: "Folder section (default: TESTCASE)",
536
+ },
537
+ name: { type: "string", description: "[create/rename] Folder name (max 50 chars)" },
538
+ folderId: {
539
+ type: "number",
540
+ description: "[rename/move/delete] ID of the folder to modify",
541
+ },
542
+ parentId: {
543
+ type: "number",
544
+ description: "[create] Parent folder ID for subfolder. [move] New parent ID (null for root).",
545
+ },
546
+ },
547
+ required: ["projectId", "action"],
548
+ },
549
+ };
550
+ const SEARCH_TEST_CYCLES_TOOL = {
551
+ name: "search_test_cycles",
552
+ description: "List and search test cycles in a project with execution progress.",
553
+ annotations: { readOnlyHint: true },
554
+ inputSchema: {
555
+ type: "object",
556
+ properties: {
557
+ projectId: { type: "string", description: "Project ID" },
558
+ search: { type: "string", description: "Search by title" },
559
+ folderId: { type: "number", description: "Filter by folder ID" },
560
+ status: { type: "string", enum: ["open", "in_progress", "completed"], description: "Filter by status" },
561
+ page: { type: "number", description: "Page number (default: 1)" },
562
+ limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
563
+ },
564
+ required: ["projectId"],
565
+ },
566
+ };
567
+ const MANAGE_TEST_CYCLE_TOOL = {
568
+ name: "manage_test_cycle",
569
+ description: "Manage test cycles: create, update, delete, get details, list/link/unlink/assign test cases.\n\n" +
570
+ "ACTIONS:\n" +
571
+ " create - Create a cycle. Params: title (required), summary, status, planned_start_date, planned_end_date, folder_id\n" +
572
+ " update - Update a cycle. Params: cycleId (required), plus fields to change (title, summary, status, dates, folder_id)\n" +
573
+ " delete - Delete cycle(s). Params: ids (required, comma-separated)\n" +
574
+ " get_details - Get cycle details. Params: cycleId (required)\n" +
575
+ " get_testcases - List test cases in cycle. Params: cycleId (required), search, page, limit\n" +
576
+ " link_testcases - Link test cases to cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
577
+ " unlink_testcases - Unlink test cases from cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
578
+ " assign_testcases - Assign test cases to user. Params: cycleId (required), testcase_ids (required), assignee_id (required)\n\n" +
579
+ "VALID STATUSES: open, in_progress, completed",
580
+ annotations: { destructiveHint: true },
581
+ inputSchema: {
582
+ type: "object",
583
+ properties: {
584
+ projectId: { type: "string", description: "Project ID" },
585
+ action: {
586
+ type: "string",
587
+ enum: [
588
+ "create",
589
+ "update",
590
+ "delete",
591
+ "get_details",
592
+ "get_testcases",
593
+ "link_testcases",
594
+ "unlink_testcases",
595
+ "assign_testcases",
596
+ ],
597
+ description: "Action to perform",
598
+ },
599
+ // Identifiers
600
+ cycleId: {
601
+ type: "string",
602
+ description: "Test cycle ID (e.g. TCYCLE-1). Required for update/delete/get_details/get_testcases/link/unlink/assign.",
603
+ },
604
+ // create/update fields
605
+ title: { type: "string", description: "[create/update] Cycle title" },
606
+ summary: { type: "string", description: "[create/update] Description" },
607
+ status: {
608
+ type: "string",
609
+ enum: ["open", "in_progress", "completed"],
610
+ description: "[create/update] Status",
611
+ },
612
+ planned_start_date: {
613
+ type: "string",
614
+ description: "[create/update] Start date (YYYY-MM-DD)",
615
+ },
616
+ planned_end_date: {
617
+ type: "string",
618
+ description: "[create/update] End date (YYYY-MM-DD)",
619
+ },
620
+ folder_id: {
621
+ type: "number",
622
+ description: "[create/update] Folder ID",
623
+ },
624
+ // delete
625
+ ids: {
626
+ type: "string",
627
+ description: "[delete] Comma-separated cycle IDs to delete",
628
+ },
629
+ // link/unlink/assign
630
+ testcase_ids: {
631
+ type: "string",
632
+ description: "[link_testcases/unlink_testcases/assign_testcases] Comma-separated test case IDs",
633
+ },
634
+ assignee_id: {
635
+ type: "number",
636
+ description: "[assign_testcases] User ID to assign",
637
+ },
638
+ // get_testcases params
639
+ search: { type: "string", description: "[get_testcases] Search text" },
640
+ page: { type: "number", description: "[get_testcases] Page number" },
641
+ limit: { type: "number", description: "[get_testcases] Items per page" },
642
+ },
643
+ required: ["projectId", "action"],
644
+ },
645
+ };
646
+ const EXECUTE_TESTS_TOOL = {
647
+ name: "execute_tests",
648
+ description: "Record test execution results for test cases in a test cycle. " +
649
+ "Pass a single result or an array of results. " +
650
+ "Valid execution statuses: pass, fail, blocked, not_executed. " +
651
+ "The test cases must already be linked to the cycle (manage_test_cycle link_testcases).",
652
+ inputSchema: {
653
+ type: "object",
654
+ properties: {
655
+ projectId: { type: "string", description: "Project ID" },
656
+ cycleId: { type: "string", description: "Test cycle ID (e.g. TCYCLE-1)" },
657
+ results: {
658
+ type: "string",
659
+ description: 'JSON array of results: [{"testcase_id":"TEST-1","execution_status":"pass","actual_result":"Worked","environment":"staging"}]. ' +
660
+ "Each object must have testcase_id and execution_status. actual_result and environment are optional.",
661
+ },
662
+ mode: {
663
+ type: "string",
664
+ enum: ["manual", "automation"],
665
+ description: "manual (default): each result is recorded like a tester setting it in the app — the new status replaces the old one. " +
666
+ "automation: recorded as a CI/automation run, where a failure from any earlier automation run keeps the case failed. Use only for CI results.",
667
+ },
668
+ },
669
+ required: ["projectId", "cycleId", "results"],
670
+ },
671
+ };
672
+ const MANAGE_TEST_PLAN_TOOL = {
673
+ name: "manage_test_plan",
674
+ description: "Manage test plans: create, update, delete, list, get details, link/unlink cycles, get cycles, get test cases.\n\n" +
675
+ "ACTIONS:\n" +
676
+ " create - Create a plan. Params: title (required), summary, status, planned_start_date, planned_end_date, folder_id\n" +
677
+ " update - Update a plan. Params: planId (required), plus fields to change (title, summary, status, dates, folder_id)\n" +
678
+ " delete - Delete plan(s). Params: ids (required, comma-separated)\n" +
679
+ " list - List all plans. Params: search, page, limit\n" +
680
+ " get_details - Get plan details. Params: planId (required)\n" +
681
+ " link_cycles - Link cycles to plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
682
+ " unlink_cycles - Unlink cycles from plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
683
+ " get_cycles - List cycles in plan. Params: planId (required), search, page, limit\n" +
684
+ " get_testcases - List test cases in plan. Params: planId (required), search, page, limit\n\n" +
685
+ "VALID STATUSES: open, in_progress, completed",
686
+ annotations: { destructiveHint: true },
687
+ inputSchema: {
688
+ type: "object",
689
+ properties: {
690
+ projectId: { type: "string", description: "Project ID" },
691
+ action: {
692
+ type: "string",
693
+ enum: [
694
+ "create",
695
+ "update",
696
+ "delete",
697
+ "list",
698
+ "get_details",
699
+ "link_cycles",
700
+ "unlink_cycles",
701
+ "get_cycles",
702
+ "get_testcases",
703
+ ],
704
+ description: "Action to perform",
705
+ },
706
+ // Identifier
707
+ planId: {
708
+ type: "string",
709
+ description: "Test plan ID (e.g. TPLAN-1). Required for update/delete/get_details/link_cycles/unlink_cycles/get_cycles/get_testcases.",
710
+ },
711
+ // create/update fields
712
+ title: { type: "string", description: "[create/update] Plan title" },
713
+ summary: { type: "string", description: "[create/update] Description" },
714
+ status: { type: "string", enum: ["open", "in_progress", "completed"], description: "[create/update] Status" },
715
+ planned_start_date: {
716
+ type: "string",
717
+ description: "[create/update] Start date (YYYY-MM-DD)",
718
+ },
719
+ planned_end_date: {
720
+ type: "string",
721
+ description: "[create/update] End date (YYYY-MM-DD)",
722
+ },
723
+ folder_id: { type: "number", description: "[create/update] Folder ID" },
724
+ // delete
725
+ ids: {
726
+ type: "string",
727
+ description: "[delete] Comma-separated plan IDs to delete",
728
+ },
729
+ // link/unlink cycles
730
+ cycle_ids: {
731
+ type: "string",
732
+ description: "[link_cycles/unlink_cycles] Comma-separated test cycle IDs",
733
+ },
734
+ // list/get_cycles/get_testcases pagination
735
+ search: { type: "string", description: "[list/get_cycles/get_testcases] Search text" },
736
+ page: { type: "number", description: "[list/get_cycles/get_testcases] Page number" },
737
+ limit: { type: "number", description: "[list/get_cycles/get_testcases] Items per page" },
738
+ },
739
+ required: ["projectId", "action"],
740
+ },
741
+ };
742
+ const GET_REPORT_TOOL = {
743
+ name: "get_report",
744
+ description: "Get reports and analytics for a project. Covers execution, coverage, trends, team, defects, and insights " +
745
+ "(the insight reports are computed from your data; they do not use AI credits). " +
746
+ "Available report types: execution_summary, execution_by_cycle, execution_by_tester, execution_by_priority, " +
747
+ "execution_by_environment, execution_by_folder, requirement_coverage, traceability_matrix, " +
748
+ "failed_requirements, uncovered_requirements, testcase_coverage, unlinked_testcases, " +
749
+ "defects_by_folder, tester_workload, testcase_distribution, " +
750
+ "execution_trend, execution_burnup, execution_burndown, test_creation, cycle_comparison, " +
751
+ "scorecard_by_folder, scorecard_by_tester, created_vs_executed, execution_by_automation, " +
752
+ "defects_by_cycle, defects_by_tester, requirement_coverage_trend, " +
753
+ "flaky_tests, release_readiness, risk_heatmap_folder, risk_heatmap_feature, " +
754
+ "testcase_quality, predictive_failure, smart_prioritization, tester_effectiveness, " +
755
+ "stale_tests, defect_hotspots, cycle_health, suite_optimization, " +
756
+ "requirement_risk_matrix, execution_velocity, project_health.",
757
+ annotations: { readOnlyHint: true },
758
+ inputSchema: {
759
+ type: "object",
760
+ properties: {
761
+ projectId: { type: "string", description: "Project ID" },
762
+ report_type: {
763
+ type: "string",
764
+ description: "Report type (see description for full list)",
765
+ },
766
+ test_cycle_id: {
767
+ type: "string",
768
+ description: "Only this test cycle (one ID, e.g. TCYCLE-3)",
769
+ },
770
+ test_plan_id: {
771
+ type: "string",
772
+ description: "Only this test plan (one ID, e.g. TPLAN-2)",
773
+ },
774
+ folder_id: { type: "number", description: "Only this folder" },
775
+ startDate: { type: "string", description: "Start date filter (YYYY-MM-DD)" },
776
+ endDate: { type: "string", description: "End date filter (YYYY-MM-DD)" },
777
+ granularity: {
778
+ type: "string",
779
+ enum: ["day", "week", "month"],
780
+ description: "Granularity for trend reports",
781
+ },
782
+ // cycle_comparison specific
783
+ cycle_ids: {
784
+ type: "string",
785
+ description: "[cycle_comparison] Comma-separated cycle IDs to compare",
786
+ },
787
+ },
788
+ required: ["projectId", "report_type"],
789
+ },
790
+ };
791
+ // ═══════════════════════════════════════════════════════════════════════════════
792
+ // REPORT TYPE → ENDPOINT MAPPING
793
+ // ═══════════════════════════════════════════════════════════════════════════════
794
+ const REPORT_TYPE_MAP = {
795
+ // Execution
796
+ execution_summary: "execution/summary",
797
+ execution_by_cycle: "execution/by-cycle",
798
+ execution_by_tester: "execution/by-tester",
799
+ execution_by_priority: "execution/by-priority",
800
+ execution_by_environment: "execution/by-environment",
801
+ execution_by_folder: "execution/by-folder",
802
+ // Coverage
803
+ requirement_coverage: "coverage/requirement-coverage",
804
+ traceability_matrix: "coverage/traceability-matrix",
805
+ failed_requirements: "coverage/failed-requirements",
806
+ uncovered_requirements: "coverage/uncovered-requirements",
807
+ testcase_coverage: "coverage/testcase-coverage",
808
+ unlinked_testcases: "coverage/unlinked-testcases",
809
+ // Defects
810
+ defects_by_folder: "defects/by-folder",
811
+ // Team
812
+ tester_workload: "team/tester-workload",
813
+ testcase_distribution: "team/testcase-distribution",
814
+ // Trends
815
+ execution_trend: "trends/execution-trend",
816
+ execution_burnup: "trends/execution-burnup",
817
+ execution_burndown: "trends/execution-burndown",
818
+ test_creation: "trends/test-creation",
819
+ cycle_comparison: "trends/cycle-comparison",
820
+ scorecard_by_folder: "trends/scorecard-by-folder",
821
+ scorecard_by_tester: "trends/scorecard-by-tester",
822
+ created_vs_executed: "trends/created-vs-executed",
823
+ execution_by_automation: "trends/execution-by-automation",
824
+ defects_by_cycle: "trends/defects-by-cycle",
825
+ defects_by_tester: "trends/defects-by-tester",
826
+ requirement_coverage_trend: "trends/requirement-coverage-trend",
827
+ // AI Insights
828
+ flaky_tests: "ai/flaky-tests",
829
+ release_readiness: "ai/release-readiness",
830
+ risk_heatmap_folder: "ai/risk-heatmap-folder",
831
+ risk_heatmap_feature: "ai/risk-heatmap-feature",
832
+ testcase_quality: "ai/testcase-quality",
833
+ predictive_failure: "ai/predictive-failure",
834
+ smart_prioritization: "ai/smart-prioritization",
835
+ tester_effectiveness: "ai/tester-effectiveness",
836
+ stale_tests: "ai/stale-tests",
837
+ defect_hotspots: "ai/defect-hotspots",
838
+ cycle_health: "ai/cycle-health",
839
+ suite_optimization: "ai/suite-optimization",
840
+ requirement_risk_matrix: "ai/requirement-risk-matrix",
841
+ execution_velocity: "ai/execution-velocity",
842
+ project_health: "ai/project-health",
843
+ };
844
+ // ═══════════════════════════════════════════════════════════════════════════════
845
+ // TOOL HANDLERS
846
+ // ═══════════════════════════════════════════════════════════════════════════════
847
+ // 1. list_projects
848
+ async function handleListProjects(args) {
849
+ const data = await callApi("get", "projects/user/my-projects", {
850
+ query: {
851
+ page: p(args, "page"),
852
+ limit: p(args, "limit"),
853
+ },
854
+ });
855
+ return jsonResponseWithHints(data, [
856
+ "Next: call get_project_structure(projectId) to see folders, labels, and allowed field values.",
857
+ ]);
858
+ }
859
+ // 2. get_project_structure
860
+ async function handleGetProjectStructure(args) {
861
+ const projectId = p(args, "projectId", "project_id");
862
+ if (!projectId)
863
+ throw new Error("projectId is required");
864
+ validateId(projectId, "projectId");
865
+ const section = p(args, "section") || "TESTCASE";
866
+ const includeRaw = p(args, "include") || "folders,labels,members,field_options";
867
+ const includes = includeRaw.split(",").map((s) => s.trim().toLowerCase());
868
+ const orgId = await getOrganizationId();
869
+ const result = {};
870
+ const promises = [];
871
+ if (includes.includes("folders")) {
872
+ promises.push(callApi("get", "folders/get-folders", {
873
+ query: { projectId, section },
874
+ }).then((data) => {
875
+ result.folders = data;
876
+ }));
877
+ }
878
+ if (includes.includes("labels")) {
879
+ promises.push(callApi("get", `projects/labels/get-label/${projectId}`).then((data) => {
880
+ result.labels = data;
881
+ }));
882
+ }
883
+ if (includes.includes("members")) {
884
+ promises.push(callApi("get", `projects/${projectId}/team-members`).then((data) => {
885
+ result.members = data;
886
+ }));
887
+ }
888
+ if (includes.includes("field_options")) {
889
+ promises.push(callApi("get", `projects/fields/options/${projectId}`).then((data) => {
890
+ result.field_options = data?.data || data;
891
+ }));
892
+ }
893
+ await Promise.all(promises);
894
+ return jsonResponseWithHints(result, [
895
+ "Use the field_options values above when creating/updating test cases.",
896
+ ]);
897
+ }
898
+ // 3. search_testcases
899
+ async function handleSearchTestcases(args) {
900
+ const projectId = p(args, "projectId", "project_id");
901
+ if (!projectId)
902
+ throw new Error("projectId is required");
903
+ validateId(projectId, "projectId");
904
+ const folderId = p(args, "folder_id", "folderId");
905
+ if (folderId !== undefined && folderId !== null && folderId !== "" && isNaN(Number(folderId))) {
906
+ throw new Error("folder_id must be a numeric folder ID");
907
+ }
908
+ const filters = buildFilters(p(args, "filters"), {
909
+ folder_id: folderId === undefined || folderId === null || folderId === "" ? undefined : [Number(folderId)],
910
+ });
911
+ const data = await callApi("get", `projects/testcases/get-testcase/${projectId}`, {
912
+ query: {
913
+ search: p(args, "search"),
914
+ filters,
915
+ page: p(args, "page"),
916
+ limit: p(args, "limit"),
917
+ sortBy: p(args, "sortBy", "sort_by"),
918
+ sortOrder: p(args, "sortOrder", "sort_order"),
919
+ },
920
+ });
921
+ const baseUrl = getFrontendBaseUrl();
922
+ if (Array.isArray(data?.data)) {
923
+ data.data.forEach((tc) => {
924
+ if (tc.id)
925
+ tc.url = `${baseUrl}/${projectId}/testcases/${tc.id}`;
926
+ });
927
+ }
928
+ return jsonResponse(data);
929
+ }
930
+ // 4. get_testcase
931
+ async function handleGetTestcase(args) {
932
+ const projectId = p(args, "projectId", "project_id");
933
+ const testcaseId = p(args, "testcaseId", "testcase_id", "id");
934
+ if (!projectId)
935
+ throw new Error("projectId is required");
936
+ if (!testcaseId)
937
+ throw new Error("testcaseId is required");
938
+ validateId(projectId, "projectId");
939
+ validateId(testcaseId, "testcaseId");
940
+ const data = await callApi("get", `projects/testcases/get-testcase-detail/${projectId}/${testcaseId}`);
941
+ if (data?.data?.testcase) {
942
+ data.data.testcase.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${testcaseId}`;
943
+ }
944
+ return jsonResponse(data);
945
+ }
946
+ // 5. manage_testcase
947
+ async function handleManageTestcase(args) {
948
+ const projectId = p(args, "projectId", "project_id");
949
+ const action = p(args, "action");
950
+ if (!projectId)
951
+ throw new Error("projectId is required");
952
+ if (!action)
953
+ throw new Error("action is required");
954
+ validateId(projectId, "projectId");
955
+ const orgId = await getOrganizationId();
956
+ switch (action) {
957
+ case "create": {
958
+ const title = p(args, "title", "name");
959
+ if (!title)
960
+ throw new Error("title is required for create action");
961
+ // Validate steps before creating anything, so a bad step can't leave a half-made test case
962
+ const testSteps = parseSteps(p(args, "test_steps", "testSteps", "steps"));
963
+ const testcaseData = await buildTestcasePayload(projectId, { ...args, title });
964
+ const createResult = await callApi("post", `projects/testcases/create-testcase/${projectId}`, { body: testcaseData });
965
+ const tcId = createResult?.testcaseId || createResult?.data?.id;
966
+ const hints = [
967
+ "To update fields, use manage_testcase(action='update', ids='<id>', field='<name>', value='<value>')",
968
+ ];
969
+ if (tcId) {
970
+ createResult.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${tcId}`;
971
+ if (testSteps?.length) {
972
+ const steps = await addSteps(projectId, tcId, testSteps);
973
+ createResult.steps_created = steps.created;
974
+ if (steps.errors.length) {
975
+ createResult.step_errors = steps.errors;
976
+ hints.push(`The test case ${tcId} WAS created; do not create it again. Add the missing steps with manage_testcase(action='add_steps', testcaseId='${tcId}').`);
977
+ }
978
+ }
979
+ }
980
+ return jsonResponseWithHints(createResult, hints);
981
+ }
982
+ case "create_bulk": {
983
+ const rawTestcases = p(args, "testcases", "test_cases", "items");
984
+ if (!rawTestcases)
985
+ throw new Error("testcases is required for create_bulk action");
986
+ const testcases = safeJsonParse(rawTestcases, "testcases");
987
+ if (!Array.isArray(testcases) || testcases.length === 0) {
988
+ throw new Error("testcases must be a non-empty JSON array");
989
+ }
990
+ // Validate everything before creating anything
991
+ const items = [];
992
+ for (const [i, tc] of testcases.entries()) {
993
+ if (!tc?.title && !tc?.name)
994
+ throw new Error(`testcases[${i}]: title is required`);
995
+ try {
996
+ items.push({
997
+ testcase: await buildTestcasePayload(projectId, tc),
998
+ test_steps: parseSteps(tc.test_steps ?? tc.testSteps ?? tc.steps),
999
+ });
1000
+ }
1001
+ catch (error) {
1002
+ throw new Error(`testcases[${i}]: ${error.message}`);
1003
+ }
1004
+ }
1005
+ const bulkResult = await callApi("post", `projects/testcases/create-bulk-testcase/${projectId}`, { body: { testcases: items.map((t) => t.testcase) } });
1006
+ // Add test steps for each created testcase
1007
+ const results = bulkResult?.results || [];
1008
+ let stepsCreated = 0;
1009
+ const stepErrors = {};
1010
+ for (const r of results) {
1011
+ const steps = items[r.index ?? results.indexOf(r)]?.test_steps;
1012
+ const tcId = r.testcaseId || r.id;
1013
+ if (r.status !== "success" || !tcId || !steps?.length)
1014
+ continue;
1015
+ const res = await addSteps(projectId, tcId, steps);
1016
+ stepsCreated += res.created;
1017
+ if (res.errors.length)
1018
+ stepErrors[tcId] = res.errors;
1019
+ }
1020
+ const response = { ...bulkResult, steps_created: stepsCreated };
1021
+ if (Object.keys(stepErrors).length)
1022
+ response.step_errors = stepErrors;
1023
+ return jsonResponseWithHints(response, Object.keys(stepErrors).length
1024
+ ? ["Some steps failed (see step_errors). The test cases WERE created; add the missing steps with manage_testcase(action='add_steps')."]
1025
+ : []);
1026
+ }
1027
+ case "update": {
1028
+ const ids = p(args, "ids", "testcase_ids", "testcaseIds");
1029
+ const field = p(args, "field", "field_name", "fieldName");
1030
+ const value = p(args, "value", "field_value", "fieldValue", "new_value");
1031
+ if (!ids)
1032
+ throw new Error("ids is required for update action");
1033
+ if (!field)
1034
+ throw new Error("field is required for update action");
1035
+ if (value === undefined)
1036
+ throw new Error("value is required for update action");
1037
+ if (!TESTCASE_FIELDS.split(", ").includes(field)) {
1038
+ throw new Error(`Invalid field: ${field}. Valid fields: ${TESTCASE_FIELDS}`);
1039
+ }
1040
+ const idsStr = toIdList(ids);
1041
+ // Coerce field values for project-specific fields
1042
+ const coercibleFields = ["priority", "status", "automated", "platform"];
1043
+ // Parse value for fields that expect non-string types
1044
+ let parsedValue = value;
1045
+ if (field === "folder_id" || field === "owner_id") {
1046
+ const num = Number(value);
1047
+ parsedValue = isNaN(num) ? value : num;
1048
+ }
1049
+ else if (coercibleFields.includes(field)) {
1050
+ parsedValue = await coerceFieldValue(projectId, field, value);
1051
+ }
1052
+ else if (field === "labels" || field === "features" || field === "environment") {
1053
+ parsedValue = typeof value === "string" ? value.split(",").map((s) => s.trim()).filter(Boolean) : value;
1054
+ }
1055
+ else if (field === "custom_fields") {
1056
+ const parsed = safeJsonParse(value, "custom_fields");
1057
+ // Backend requires all custom field values as arrays
1058
+ for (const key of Object.keys(parsed)) {
1059
+ if (!Array.isArray(parsed[key])) {
1060
+ parsed[key] = [parsed[key]];
1061
+ }
1062
+ }
1063
+ parsedValue = parsed;
1064
+ }
1065
+ const data = await callApi("patch", `projects/testcases/update-field/${projectId}`, {
1066
+ body: { ids: idsStr, [field]: parsedValue },
1067
+ });
1068
+ return jsonResponse(data);
1069
+ }
1070
+ case "delete": {
1071
+ const ids = p(args, "ids", "testcase_ids", "testcaseIds");
1072
+ if (!ids)
1073
+ throw new Error("ids is required for delete action");
1074
+ const data = await callApi("delete", `projects/testcases/delete-testcase/${projectId}`, {
1075
+ query: { ids: toIdList(ids) },
1076
+ });
1077
+ return jsonResponse(data);
1078
+ }
1079
+ case "add_steps": {
1080
+ const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
1081
+ const steps = parseSteps(p(args, "test_steps", "testSteps", "steps"));
1082
+ if (!steps?.length)
1083
+ throw new Error("test_steps is required for add_steps action");
1084
+ const res = await addSteps(projectId, tcId, steps);
1085
+ if (res.created === 0)
1086
+ throw new Error(`No steps were added:\n${res.errors.join("\n")}`);
1087
+ return jsonResponse({ testcaseId: tcId, steps_created: res.created, step_errors: res.errors });
1088
+ }
1089
+ case "update_step": {
1090
+ const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
1091
+ const stepId = Number(requireArg(args, "step_id", ["stepId"]));
1092
+ if (isNaN(stepId))
1093
+ throw new Error("step_id must be a number");
1094
+ const body = { stepId };
1095
+ for (const f of ["description", "test_data", "expected_result"]) {
1096
+ if (args[f] !== undefined)
1097
+ body[f] = args[f];
1098
+ }
1099
+ // The backend requires a description string even when it is unchanged
1100
+ if (body.description === undefined) {
1101
+ const detail = await callApi("get", `projects/testcases/get-testcase-detail/${projectId}/${tcId}`);
1102
+ const existing = (detail?.data?.steps || []).find((st) => Number(st.id) === stepId);
1103
+ if (!existing)
1104
+ throw new Error(`Step ${stepId} not found on ${tcId}`);
1105
+ body.description = existing.description ?? "";
1106
+ }
1107
+ const data = await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, { body });
1108
+ return jsonResponse(data);
1109
+ }
1110
+ case "delete_steps": {
1111
+ const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
1112
+ const stepIds = toIdList(requireArg(args, "step_ids", ["stepIds"]));
1113
+ const data = await callApi("delete", `projects/testcases/teststeps/${tcId}`, {
1114
+ query: { projectId },
1115
+ body: { stepIds },
1116
+ });
1117
+ return jsonResponse(data);
1118
+ }
1119
+ case "reorder_steps": {
1120
+ const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
1121
+ const stepIds = toStringArray(requireArg(args, "step_ids", ["stepIds"])).map(Number);
1122
+ if (stepIds.some(isNaN))
1123
+ throw new Error("step_ids must be numeric step IDs");
1124
+ const data = await callApi("put", `projects/testcases/teststeps/${projectId}/${tcId}/reorder`, {
1125
+ body: { stepIds },
1126
+ });
1127
+ return jsonResponse(data);
1128
+ }
1129
+ default:
1130
+ throw new Error(`Unknown action: ${action}. Use: create, create_bulk, update, delete, add_steps, update_step, delete_steps, reorder_steps`);
1131
+ }
1132
+ }
1133
+ // 6. manage_folder
1134
+ async function handleManageFolder(args) {
1135
+ const projectId = p(args, "projectId", "project_id");
1136
+ const action = p(args, "action");
1137
+ if (!projectId)
1138
+ throw new Error("projectId is required");
1139
+ if (!action)
1140
+ throw new Error("action is required");
1141
+ validateId(projectId, "projectId");
1142
+ const section = p(args, "section") || "TESTCASE";
1143
+ const orgId = await getOrganizationId();
1144
+ switch (action) {
1145
+ case "create": {
1146
+ const name = p(args, "name", "folder_name");
1147
+ if (!name)
1148
+ throw new Error("name is required for create action");
1149
+ const body = {
1150
+ organizationId: orgId,
1151
+ projectId,
1152
+ section,
1153
+ name,
1154
+ };
1155
+ const parentId = p(args, "parentId", "parent_id");
1156
+ if (parentId)
1157
+ body.parentId = Number(parentId);
1158
+ const data = await callApi("post", "folders/create-folders", { body, skipOrgId: true });
1159
+ return jsonResponse(data);
1160
+ }
1161
+ case "rename": {
1162
+ const folderId = p(args, "folderId", "folder_id", "id");
1163
+ const name = p(args, "name", "folder_name");
1164
+ if (!folderId)
1165
+ throw new Error("folderId is required for rename action");
1166
+ if (!name)
1167
+ throw new Error("name is required for rename action");
1168
+ validateId(folderId, "folderId");
1169
+ const data = await callApi("patch", `folders/${folderId}`, {
1170
+ query: { projectId, section },
1171
+ body: { name },
1172
+ });
1173
+ return jsonResponse(data);
1174
+ }
1175
+ case "move": {
1176
+ const folderId = p(args, "folderId", "folder_id", "id");
1177
+ if (!folderId)
1178
+ throw new Error("folderId is required for move action");
1179
+ validateId(folderId, "folderId");
1180
+ const parentId = p(args, "parentId", "parent_id");
1181
+ const newParentId = parentId === undefined || parentId === null || parentId === "" ? null : Number(parentId);
1182
+ if (newParentId !== null && isNaN(newParentId))
1183
+ throw new Error("parentId must be a numeric folder ID");
1184
+ const data = await callApi("patch", `folders/${folderId}/move`, {
1185
+ query: { projectId, section },
1186
+ body: { newParentId },
1187
+ });
1188
+ return jsonResponse(data);
1189
+ }
1190
+ case "delete": {
1191
+ const folderId = p(args, "folderId", "folder_id", "id");
1192
+ if (!folderId)
1193
+ throw new Error("folderId is required for delete action");
1194
+ validateId(folderId, "folderId");
1195
+ const data = await callApi("delete", `folders/delete/${folderId}`, {
1196
+ query: { projectId, section },
1197
+ });
1198
+ return jsonResponse(data);
1199
+ }
1200
+ default:
1201
+ throw new Error(`Unknown action: ${action}. Use: create, rename, move, delete`);
1202
+ }
1203
+ }
1204
+ // 7. search_test_cycles
1205
+ async function handleSearchTestCycles(args) {
1206
+ const projectId = p(args, "projectId", "project_id");
1207
+ if (!projectId)
1208
+ throw new Error("projectId is required");
1209
+ validateId(projectId, "projectId");
1210
+ const folderId = p(args, "folderId", "folder_id");
1211
+ const filters = buildFilters(undefined, {
1212
+ status: p(args, "status"),
1213
+ folder_id: folderId === undefined || folderId === null || folderId === "" ? undefined : Number(folderId),
1214
+ });
1215
+ const data = await callApi("get", `projects/test-cycles/get-test-cycles/${projectId}`, {
1216
+ query: {
1217
+ search: p(args, "search"),
1218
+ filters,
1219
+ page: p(args, "page"),
1220
+ limit: p(args, "limit"),
1221
+ },
1222
+ });
1223
+ const baseUrl = getFrontendBaseUrl();
1224
+ if (Array.isArray(data?.data)) {
1225
+ data.data.forEach((cycle) => {
1226
+ const id = cycle.test_cycle_id || cycle.id;
1227
+ if (id)
1228
+ cycle.url = `${baseUrl}/${projectId}/testcycles/${id}`;
1229
+ });
1230
+ }
1231
+ return jsonResponse(data);
1232
+ }
1233
+ // 8. manage_test_cycle
1234
+ async function handleManageTestCycle(args) {
1235
+ const projectId = p(args, "projectId", "project_id");
1236
+ const action = p(args, "action");
1237
+ if (!projectId)
1238
+ throw new Error("projectId is required");
1239
+ if (!action)
1240
+ throw new Error("action is required");
1241
+ validateId(projectId, "projectId");
1242
+ switch (action) {
1243
+ case "create": {
1244
+ const title = p(args, "title");
1245
+ if (!title)
1246
+ throw new Error("title is required for create action");
1247
+ const body = { title };
1248
+ if (p(args, "summary"))
1249
+ body.summary = p(args, "summary");
1250
+ if (p(args, "status"))
1251
+ body.status = p(args, "status");
1252
+ if (p(args, "planned_start_date", "plannedStartDate"))
1253
+ body.planned_start_date = p(args, "planned_start_date", "plannedStartDate");
1254
+ if (p(args, "planned_end_date", "plannedEndDate"))
1255
+ body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
1256
+ if (p(args, "folder_id", "folderId"))
1257
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
1258
+ const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
1259
+ return jsonResponse(data);
1260
+ }
1261
+ case "update": {
1262
+ const cycleId = p(args, "cycleId", "cycle_id", "id");
1263
+ if (!cycleId)
1264
+ throw new Error("cycleId is required for update action");
1265
+ const body = { id: cycleId };
1266
+ if (p(args, "title"))
1267
+ body.title = p(args, "title");
1268
+ if (p(args, "summary"))
1269
+ body.summary = p(args, "summary");
1270
+ if (p(args, "status"))
1271
+ body.status = p(args, "status");
1272
+ if (p(args, "planned_start_date", "plannedStartDate"))
1273
+ body.planned_start_date = p(args, "planned_start_date", "plannedStartDate");
1274
+ if (p(args, "planned_end_date", "plannedEndDate"))
1275
+ body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
1276
+ if (p(args, "folder_id", "folderId"))
1277
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
1278
+ const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
1279
+ return jsonResponse(data);
1280
+ }
1281
+ case "delete": {
1282
+ const ids = p(args, "ids", "cycleId", "cycle_id");
1283
+ if (!ids)
1284
+ throw new Error("ids (or cycleId) is required for delete action");
1285
+ const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
1286
+ const data = await callApi("delete", `projects/test-cycles/delete-test-cycle/${projectId}`, { body: { ids: idsStr } });
1287
+ return jsonResponse(data);
1288
+ }
1289
+ case "get_details": {
1290
+ const cycleId = p(args, "cycleId", "cycle_id", "id");
1291
+ if (!cycleId)
1292
+ throw new Error("cycleId is required for get_details action");
1293
+ validateId(cycleId, "cycleId");
1294
+ const data = await callApi("get", `projects/test-cycles/get-test-cycle-details/${projectId}/${cycleId}`);
1295
+ if (data?.data) {
1296
+ data.data.url = `${getFrontendBaseUrl()}/${projectId}/testcycles/${cycleId}`;
1297
+ }
1298
+ return jsonResponse(data);
1299
+ }
1300
+ case "get_testcases": {
1301
+ const cycleId = p(args, "cycleId", "cycle_id", "id");
1302
+ if (!cycleId)
1303
+ throw new Error("cycleId is required for get_testcases action");
1304
+ validateId(cycleId, "cycleId");
1305
+ const data = await callApi("get", `projects/test-cycles/testcases/${projectId}/${cycleId}`, {
1306
+ query: {
1307
+ search: p(args, "search"),
1308
+ page: p(args, "page"),
1309
+ limit: p(args, "limit"),
1310
+ },
1311
+ });
1312
+ return jsonResponse(data);
1313
+ }
1314
+ case "link_testcases": {
1315
+ const cycleId = p(args, "cycleId", "cycle_id", "id");
1316
+ const tcIds = p(args, "testcase_ids", "testcaseIds");
1317
+ if (!cycleId)
1318
+ throw new Error("cycleId is required for link_testcases action");
1319
+ if (!tcIds)
1320
+ throw new Error("testcase_ids is required for link_testcases action");
1321
+ validateId(cycleId, "cycleId");
1322
+ const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
1323
+ const data = await callApi("post", `projects/test-cycles/testcases/link/${projectId}/${cycleId}`, { body: { ids: idsStr } });
1324
+ return jsonResponse(data);
1325
+ }
1326
+ case "unlink_testcases": {
1327
+ const cycleId = p(args, "cycleId", "cycle_id", "id");
1328
+ const tcIds = p(args, "testcase_ids", "testcaseIds");
1329
+ if (!cycleId)
1330
+ throw new Error("cycleId is required for unlink_testcases action");
1331
+ if (!tcIds)
1332
+ throw new Error("testcase_ids is required for unlink_testcases action");
1333
+ validateId(cycleId, "cycleId");
1334
+ const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
1335
+ const data = await callApi("delete", `projects/test-cycles/testcases/unlink/${projectId}/${cycleId}`, { body: { ids: idsStr } });
1336
+ return jsonResponse(data);
1337
+ }
1338
+ case "assign_testcases": {
1339
+ const cycleId = p(args, "cycleId", "cycle_id", "id");
1340
+ const tcIds = p(args, "testcase_ids", "testcaseIds");
1341
+ const assigneeId = p(args, "assignee_id", "assigneeId");
1342
+ if (!cycleId)
1343
+ throw new Error("cycleId is required for assign_testcases action");
1344
+ if (!tcIds)
1345
+ throw new Error("testcase_ids is required for assign_testcases action");
1346
+ if (!assigneeId)
1347
+ throw new Error("assignee_id is required for assign_testcases action");
1348
+ validateId(cycleId, "cycleId");
1349
+ const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
1350
+ const data = await callApi("patch", `projects/test-cycles/testcases/assign/${projectId}/${cycleId}`, { body: { ids: idsStr, assignee: Number(assigneeId) } });
1351
+ return jsonResponse(data);
1352
+ }
1353
+ default:
1354
+ throw new Error(`Unknown action: ${action}. Use: create, update, delete, get_details, get_testcases, link_testcases, unlink_testcases, assign_testcases`);
1355
+ }
1356
+ }
1357
+ // 9. execute_tests
1358
+ async function handleExecuteTests(args) {
1359
+ const projectId = p(args, "projectId", "project_id");
1360
+ const cycleId = p(args, "cycleId", "cycle_id");
1361
+ const resultsRaw = p(args, "results");
1362
+ if (!projectId)
1363
+ throw new Error("projectId is required");
1364
+ if (!cycleId)
1365
+ throw new Error("cycleId is required");
1366
+ if (!resultsRaw)
1367
+ throw new Error("results is required");
1368
+ validateId(projectId, "projectId");
1369
+ validateId(cycleId, "cycleId");
1370
+ const results = safeJsonParse(resultsRaw, "results");
1371
+ if (!Array.isArray(results) || results.length === 0) {
1372
+ throw new Error("results must be a non-empty array");
1373
+ }
1374
+ const mode = p(args, "mode") || "manual";
1375
+ if (mode !== "manual" && mode !== "automation") {
1376
+ throw new Error("mode must be 'manual' or 'automation'");
1377
+ }
1378
+ // Validate all results before recording any
1379
+ const normalized = results.map((r, i) => {
1380
+ const testcaseId = r.testcase_id || r.testcaseId;
1381
+ const status = r.execution_status || r.executionStatus;
1382
+ if (!testcaseId)
1383
+ throw new Error(`results[${i}]: testcase_id is required`);
1384
+ if (!status) {
1385
+ throw new Error(`results[${i}]: execution_status is required. Valid values: pass, fail, blocked, not_executed`);
1386
+ }
1387
+ const out = {
1388
+ testcase_id: validateId(testcaseId, "testcase_id"),
1389
+ execution_status: status,
1390
+ };
1391
+ const actual = r.actual_result || r.actualResult;
1392
+ if (actual)
1393
+ out.actual_result = actual;
1394
+ if (r.environment)
1395
+ out.environment = r.environment;
1396
+ return out;
1397
+ });
1398
+ if (mode === "automation") {
1399
+ const data = await callApi("post", `projects/test-cycles/execute-bulk/${projectId}/${cycleId}`, { body: { results: normalized } });
1400
+ const errors = data?.data?.errors || data?.errors;
1401
+ return jsonResponseWithHints(data, Array.isArray(errors) && errors.length
1402
+ ? ["Some results were NOT recorded (see errors) — e.g. test cases not linked to this cycle."]
1403
+ : []);
1404
+ }
1405
+ // Manual: same endpoint the app uses when a tester sets a result
1406
+ const recorded = [];
1407
+ const failed = [];
1408
+ for (const { testcase_id, ...body } of normalized) {
1409
+ try {
1410
+ await callApi("patch", `projects/test-cycles/testcases/update-execution/${projectId}/${cycleId}/${testcase_id}`, { body });
1411
+ recorded.push(testcase_id);
1412
+ }
1413
+ catch (error) {
1414
+ const err = axios.isAxiosError(error) ? handleApiError(error) : error;
1415
+ failed.push({ testcase_id, error: err.message });
1416
+ }
1417
+ }
1418
+ if (recorded.length === 0) {
1419
+ throw new Error(`No results were recorded:\n${failed.map((f) => `${f.testcase_id}: ${f.error}`).join("\n")}`);
1420
+ }
1421
+ return jsonResponseWithHints({ status: failed.length ? "partial_success" : "success", recorded, failed }, failed.length ? ["Some results were NOT recorded (see failed)."] : []);
1422
+ }
1423
+ // 10. manage_test_plan
1424
+ async function handleManageTestPlan(args) {
1425
+ const projectId = p(args, "projectId", "project_id");
1426
+ const action = p(args, "action");
1427
+ if (!projectId)
1428
+ throw new Error("projectId is required");
1429
+ if (!action)
1430
+ throw new Error("action is required");
1431
+ validateId(projectId, "projectId");
1432
+ switch (action) {
1433
+ case "create": {
1434
+ const title = p(args, "title");
1435
+ if (!title)
1436
+ throw new Error("title is required for create action");
1437
+ const body = { title };
1438
+ if (p(args, "summary"))
1439
+ body.summary = p(args, "summary");
1440
+ if (p(args, "status"))
1441
+ body.status = p(args, "status");
1442
+ if (p(args, "planned_start_date", "plannedStartDate"))
1443
+ body.planned_start_date = p(args, "planned_start_date", "plannedStartDate");
1444
+ if (p(args, "planned_end_date", "plannedEndDate"))
1445
+ body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
1446
+ if (p(args, "folder_id", "folderId"))
1447
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
1448
+ const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
1449
+ return jsonResponse(data);
1450
+ }
1451
+ case "update": {
1452
+ const planId = p(args, "planId", "plan_id", "id");
1453
+ if (!planId)
1454
+ throw new Error("planId is required for update action");
1455
+ const body = { id: planId };
1456
+ if (p(args, "title"))
1457
+ body.title = p(args, "title");
1458
+ if (p(args, "summary"))
1459
+ body.summary = p(args, "summary");
1460
+ if (p(args, "status"))
1461
+ body.status = p(args, "status");
1462
+ if (p(args, "planned_start_date", "plannedStartDate"))
1463
+ body.planned_start_date = p(args, "planned_start_date", "plannedStartDate");
1464
+ if (p(args, "planned_end_date", "plannedEndDate"))
1465
+ body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
1466
+ if (p(args, "folder_id", "folderId"))
1467
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
1468
+ const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
1469
+ return jsonResponse(data);
1470
+ }
1471
+ case "delete": {
1472
+ const ids = p(args, "ids", "planId", "plan_id");
1473
+ if (!ids)
1474
+ throw new Error("ids (or planId) is required for delete action");
1475
+ const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
1476
+ const data = await callApi("delete", `projects/test-plans/delete-test-plan/${projectId}`, { body: { ids: idsStr } });
1477
+ return jsonResponse(data);
1478
+ }
1479
+ case "list": {
1480
+ const data = await callApi("get", `projects/test-plans/get-test-plans/${projectId}`, {
1481
+ query: {
1482
+ search: p(args, "search"),
1483
+ page: p(args, "page"),
1484
+ limit: p(args, "limit"),
1485
+ },
1486
+ });
1487
+ const baseUrl = getFrontendBaseUrl();
1488
+ if (Array.isArray(data?.data)) {
1489
+ data.data.forEach((plan) => {
1490
+ const id = plan.test_plan_id || plan.id;
1491
+ if (id)
1492
+ plan.url = `${baseUrl}/${projectId}/testplans/${id}`;
1493
+ });
1494
+ }
1495
+ return jsonResponse(data);
1496
+ }
1497
+ case "get_details": {
1498
+ const planId = p(args, "planId", "plan_id", "id");
1499
+ if (!planId)
1500
+ throw new Error("planId is required for get_details action");
1501
+ validateId(planId, "planId");
1502
+ const data = await callApi("get", `projects/test-plans/get-test-plan-details/${projectId}/${planId}`);
1503
+ if (data?.data) {
1504
+ data.data.url = `${getFrontendBaseUrl()}/${projectId}/testplans/${planId}`;
1505
+ }
1506
+ return jsonResponse(data);
1507
+ }
1508
+ case "link_cycles": {
1509
+ const planId = p(args, "planId", "plan_id", "id");
1510
+ const cycleIds = p(args, "cycle_ids", "cycleIds");
1511
+ if (!planId)
1512
+ throw new Error("planId is required for link_cycles action");
1513
+ if (!cycleIds)
1514
+ throw new Error("cycle_ids is required for link_cycles action");
1515
+ validateId(planId, "planId");
1516
+ const idsStr = Array.isArray(cycleIds) ? cycleIds.join(",") : String(cycleIds);
1517
+ const data = await callApi("post", `projects/test-plans/testcycles/link/${projectId}/${planId}`, { body: { ids: idsStr } });
1518
+ return jsonResponse(data);
1519
+ }
1520
+ case "unlink_cycles": {
1521
+ const planId = p(args, "planId", "plan_id", "id");
1522
+ const cycleIds = p(args, "cycle_ids", "cycleIds");
1523
+ if (!planId)
1524
+ throw new Error("planId is required for unlink_cycles action");
1525
+ if (!cycleIds)
1526
+ throw new Error("cycle_ids is required for unlink_cycles action");
1527
+ validateId(planId, "planId");
1528
+ const idsStr = Array.isArray(cycleIds) ? cycleIds.join(",") : String(cycleIds);
1529
+ const data = await callApi("delete", `projects/test-plans/testcycles/unlink/${projectId}/${planId}`, { body: { ids: idsStr } });
1530
+ return jsonResponse(data);
1531
+ }
1532
+ case "get_cycles": {
1533
+ const planId = p(args, "planId", "plan_id", "id");
1534
+ if (!planId)
1535
+ throw new Error("planId is required for get_cycles action");
1536
+ validateId(planId, "planId");
1537
+ const data = await callApi("get", `projects/test-plans/testcycles/list/${projectId}/${planId}`, {
1538
+ query: {
1539
+ search: p(args, "search"),
1540
+ page: p(args, "page"),
1541
+ limit: p(args, "limit"),
1542
+ },
1543
+ });
1544
+ return jsonResponse(data);
1545
+ }
1546
+ case "get_testcases": {
1547
+ const planId = p(args, "planId", "plan_id", "id");
1548
+ if (!planId)
1549
+ throw new Error("planId is required for get_testcases action");
1550
+ validateId(planId, "planId");
1551
+ const data = await callApi("get", `projects/test-plans/testcases/list/${projectId}/${planId}`, {
1552
+ query: {
1553
+ search: p(args, "search"),
1554
+ page: p(args, "page"),
1555
+ limit: p(args, "limit"),
1556
+ },
1557
+ });
1558
+ return jsonResponse(data);
1559
+ }
1560
+ default:
1561
+ throw new Error(`Unknown action: ${action}. Use: create, update, delete, list, get_details, link_cycles, unlink_cycles, get_cycles, get_testcases`);
1562
+ }
1563
+ }
1564
+ // 11. get_report
1565
+ async function handleGetReport(args) {
1566
+ const projectId = p(args, "projectId", "project_id");
1567
+ const reportType = p(args, "report_type", "reportType");
1568
+ if (!projectId)
1569
+ throw new Error("projectId is required");
1570
+ validateId(projectId, "projectId");
1571
+ if (!reportType)
1572
+ throw new Error("report_type is required");
1573
+ const endpointPath = REPORT_TYPE_MAP[reportType];
1574
+ if (!endpointPath) {
1575
+ throw new Error(`Unknown report_type: ${reportType}. Valid types: ${Object.keys(REPORT_TYPE_MAP).join(", ")}`);
1576
+ }
1577
+ const query = {};
1578
+ // Common filters (the backend takes a single cycle / plan id)
1579
+ const singleId = (value, name) => {
1580
+ if (!value)
1581
+ return undefined;
1582
+ const list = toStringArray(value);
1583
+ if (list.length > 1) {
1584
+ throw new Error(`${name} takes one ID. Call get_report once per ID, or use cycle_comparison for several cycles.`);
1585
+ }
1586
+ return validateId(list[0], name);
1587
+ };
1588
+ query.testCycleId = singleId(p(args, "test_cycle_id", "test_cycle_ids", "testCycleId", "testCycleIds"), "test_cycle_id");
1589
+ query.testPlanId = singleId(p(args, "test_plan_id", "test_plan_ids", "testPlanId", "testPlanIds"), "test_plan_id");
1590
+ if (p(args, "folder_id", "folderId"))
1591
+ query.folderId = String(p(args, "folder_id", "folderId"));
1592
+ if (p(args, "startDate", "start_date", "dateFrom"))
1593
+ query.dateFrom = p(args, "startDate", "start_date", "dateFrom");
1594
+ const endDate = p(args, "endDate", "end_date", "dateTo");
1595
+ // The backend compares with <=, so a bare date would stop at midnight and drop the end day
1596
+ if (endDate)
1597
+ query.dateTo = /^\d{4}-\d{2}-\d{2}$/.test(endDate) ? `${endDate}T23:59:59.999Z` : endDate;
1598
+ if (p(args, "granularity"))
1599
+ query.granularity = p(args, "granularity");
1600
+ // cycle_comparison specific
1601
+ if (p(args, "cycle_ids", "cycleIds"))
1602
+ query.cycleIds = p(args, "cycle_ids", "cycleIds");
1603
+ const data = await callApi("get", `projects/reports/${endpointPath}/${projectId}`, {
1604
+ query,
1605
+ });
1606
+ return jsonResponse(data);
1607
+ }
1608
+ // ═══════════════════════════════════════════════════════════════════════════════
1609
+ // SERVER SETUP
1610
+ // ═══════════════════════════════════════════════════════════════════════════════
1611
+ const server = new Server({ name: "testkase-test-management", version: VERSION }, { capabilities: { tools: {} } });
1612
+ // List all 11 tools
1613
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1614
+ tools: [
1615
+ LIST_PROJECTS_TOOL,
1616
+ GET_PROJECT_STRUCTURE_TOOL,
1617
+ SEARCH_TESTCASES_TOOL,
1618
+ GET_TESTCASE_TOOL,
1619
+ MANAGE_TESTCASE_TOOL,
1620
+ MANAGE_FOLDER_TOOL,
1621
+ SEARCH_TEST_CYCLES_TOOL,
1622
+ MANAGE_TEST_CYCLE_TOOL,
1623
+ EXECUTE_TESTS_TOOL,
1624
+ MANAGE_TEST_PLAN_TOOL,
1625
+ GET_REPORT_TOOL,
1626
+ ],
1627
+ }));
1628
+ // Route tool calls
1629
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1630
+ const { name, arguments: args } = request.params;
1631
+ try {
1632
+ switch (name) {
1633
+ case "list_projects":
1634
+ return await handleListProjects(args);
1635
+ case "get_project_structure":
1636
+ return await handleGetProjectStructure(args);
1637
+ case "search_testcases":
1638
+ return await handleSearchTestcases(args);
1639
+ case "get_testcase":
1640
+ return await handleGetTestcase(args);
1641
+ case "manage_testcase":
1642
+ return await handleManageTestcase(args);
1643
+ case "manage_folder":
1644
+ return await handleManageFolder(args);
1645
+ case "search_test_cycles":
1646
+ return await handleSearchTestCycles(args);
1647
+ case "manage_test_cycle":
1648
+ return await handleManageTestCycle(args);
1649
+ case "execute_tests":
1650
+ return await handleExecuteTests(args);
1651
+ case "manage_test_plan":
1652
+ return await handleManageTestPlan(args);
1653
+ case "get_report":
1654
+ return await handleGetReport(args);
1655
+ default:
1656
+ throw new Error(`Unknown tool: ${name}`);
1657
+ }
1658
+ }
1659
+ catch (error) {
1660
+ const err = axios.isAxiosError(error) ? handleApiError(error) : (error instanceof Error ? error : new Error(String(error)));
1661
+ const enrichedMessage = enrichErrorMessage(err.message);
1662
+ return {
1663
+ content: [{ type: "text", text: `Error: ${enrichedMessage}` }],
1664
+ isError: true,
1665
+ };
1666
+ }
1667
+ });
1668
+ // Start
1669
+ async function main() {
1670
+ const transport = new StdioServerTransport();
1671
+ await server.connect(transport);
1672
+ console.error(`TestKase MCP Server v${VERSION} running on stdio (11 tools) → ${config.apiBaseUrl}`);
1673
+ }
1674
+ main().catch((error) => {
1675
+ console.error("Fatal error in main():", error);
1676
+ process.exit(1);
1677
+ });
1678
+ //# sourceMappingURL=index.js.map