@testkase/mcp-server 2.2.0 → 2.3.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 CHANGED
@@ -4,12 +4,28 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import axios from "axios";
6
6
  const config = {
7
- apiBaseUrl: process.env.TESTKASE_API_BASE_URL || "https://apiqa.testkase.com",
7
+ apiBaseUrl: (process.env.TESTKASE_API_BASE_URL || "https://api.testkase.com").replace(/\/+$/, ""),
8
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(/\/+$/, ""),
9
14
  };
15
+ const VERSION = "2.3.0";
16
+ const REQUEST_TIMEOUT_MS = 60_000;
10
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
+ };
11
23
  function getFrontendBaseUrl() {
24
+ if (config.appUrl)
25
+ return config.appUrl;
12
26
  const url = new URL(config.apiBaseUrl);
27
+ if (KNOWN_APP_HOSTS[url.hostname])
28
+ return KNOWN_APP_HOSTS[url.hostname];
13
29
  url.hostname = url.hostname.replace(/^api\.?/, "");
14
30
  return url.origin;
15
31
  }
@@ -33,11 +49,13 @@ function getApiHeaders() {
33
49
  // ─── Cached org ID (auto-resolved from PAT, never a tool param) ─────────────
34
50
  let cachedOrganizationId = null;
35
51
  async function getOrganizationId() {
52
+ if (config.organizationId && !isNaN(config.organizationId))
53
+ return config.organizationId;
36
54
  if (cachedOrganizationId !== null)
37
55
  return cachedOrganizationId;
38
56
  validateAuth();
39
57
  try {
40
- const response = await axios.get(`${config.apiBaseUrl}/api/v1/organizations/my-organization`, { headers: getApiHeaders() });
58
+ const response = await axios.get(`${config.apiBaseUrl}/api/v1/organizations/my-organization`, { headers: getApiHeaders(), timeout: REQUEST_TIMEOUT_MS });
41
59
  const orgData = response.data?.data || response.data;
42
60
  if (orgData?.organization_id) {
43
61
  cachedOrganizationId = orgData.organization_id;
@@ -48,7 +66,10 @@ async function getOrganizationId() {
48
66
  catch (error) {
49
67
  if (error instanceof Error && error.message.includes("Could not determine"))
50
68
  throw error;
51
- throw new Error("Failed to fetch organization. Check your PAT token.");
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.`);
52
73
  }
53
74
  }
54
75
  // ─── API call helper ─────────────────────────────────────────────────────────
@@ -71,9 +92,16 @@ async function callApi(method, path, options = {}) {
71
92
  method,
72
93
  url,
73
94
  headers: getApiHeaders(),
95
+ timeout: REQUEST_TIMEOUT_MS,
74
96
  ...(options.body !== undefined && { data: options.body }),
75
97
  });
76
- return response.data;
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;
77
105
  }
78
106
  // ─── Error handler (reused) ──────────────────────────────────────────────────
79
107
  function handleApiError(error) {
@@ -113,10 +141,119 @@ function safeJsonParse(raw, fieldName) {
113
141
  }
114
142
  }
115
143
  function validateId(id, fieldName) {
116
- if (!/^[a-zA-Z0-9_\-]+$/.test(id)) {
144
+ const str = String(id);
145
+ if (!/^[a-zA-Z0-9_\-]+$/.test(str)) {
117
146
  throw new Error(`Invalid ${fieldName}: contains disallowed characters`);
118
147
  }
119
- return id;
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;
120
257
  }
121
258
  function jsonResponseWithHints(data, hints) {
122
259
  const json = JSON.stringify(data, null, 2);
@@ -136,7 +273,9 @@ const ERROR_HINTS = [
136
273
  { pattern: /folder_id/i,
137
274
  hint: "RECOVERY: folder_id must be a numeric folder ID. Call get_project_structure(projectId, include='folders') to list folders with their IDs." },
138
275
  { pattern: /status.*(401|unauthorized)/i,
139
- hint: "RECOVERY: PAT token may be expired. User needs to regenerate from TestKase account settings." },
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." },
140
279
  { pattern: /priority/i,
141
280
  hint: "RECOVERY: Priority values are project-specific. Call get_project_structure(projectId, include='field_options') to discover allowed values." },
142
281
  ];
@@ -192,6 +331,13 @@ function p(args, ...names) {
192
331
  }
193
332
  return undefined;
194
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
+ }
195
341
  // ═══════════════════════════════════════════════════════════════════════════════
196
342
  // TOOL DEFINITIONS (11 tools)
197
343
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -199,12 +345,12 @@ const LIST_PROJECTS_TOOL = {
199
345
  name: "list_projects",
200
346
  description: "List all projects the authenticated user has access to. " +
201
347
  "Use this first to find a projectId, then pass it to other tools.",
348
+ annotations: { readOnlyHint: true },
202
349
  inputSchema: {
203
350
  type: "object",
204
351
  properties: {
205
- search: { type: "string", description: "Search by project name or ID" },
206
352
  page: { type: "number", description: "Page number (default: 1)" },
207
- limit: { type: "number", description: "Items per page (default: 20)" },
353
+ limit: { type: "number", description: "Items per page (default: 12)" },
208
354
  },
209
355
  required: [],
210
356
  },
@@ -213,6 +359,7 @@ const GET_PROJECT_STRUCTURE_TOOL = {
213
359
  name: "get_project_structure",
214
360
  description: "Get the structure of a project: folders, labels, team members, and field options (allowed values for priority, status, etc.). " +
215
361
  "IMPORTANT: Call this before create/update to discover allowed field values, which are project-specific.",
362
+ annotations: { readOnlyHint: true },
216
363
  inputSchema: {
217
364
  type: "object",
218
365
  properties: {
@@ -232,18 +379,24 @@ const GET_PROJECT_STRUCTURE_TOOL = {
232
379
  };
233
380
  const SEARCH_TESTCASES_TOOL = {
234
381
  name: "search_testcases",
235
- description: "Search and list test cases in a project. Supports text search, filters, sorting, and pagination.",
382
+ description: "Search and list test cases in a project. Supports text search, folder filter, filters, sorting, and pagination.",
383
+ annotations: { readOnlyHint: true },
236
384
  inputSchema: {
237
385
  type: "object",
238
386
  properties: {
239
387
  projectId: { type: "string", description: "Project ID" },
240
- search: { type: "string", description: "Search text (title or summary)" },
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
+ },
241
393
  filters: {
242
394
  type: "string",
243
- description: 'JSON string of filters. Example: {"status":["active"],"priority":["high","critical"]}',
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".',
244
397
  },
245
398
  page: { type: "number", description: "Page number (default: 1)" },
246
- limit: { type: "number", description: "Items per page (default: 20)" },
399
+ limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
247
400
  sortBy: { type: "string", description: "Field to sort by (e.g. created_at, title)" },
248
401
  sortOrder: { type: "string", enum: ["asc", "desc"], description: "Sort direction" },
249
402
  },
@@ -252,7 +405,8 @@ const SEARCH_TESTCASES_TOOL = {
252
405
  };
253
406
  const GET_TESTCASE_TOOL = {
254
407
  name: "get_testcase",
255
- description: "Get full details of a single test case including test steps and attachments.",
408
+ description: "Get full details of a single test case including test steps (with step ids) and attachments.",
409
+ annotations: { readOnlyHint: true },
256
410
  inputSchema: {
257
411
  type: "object",
258
412
  properties: {
@@ -262,27 +416,34 @@ const GET_TESTCASE_TOOL = {
262
416
  required: ["projectId", "testcaseId"],
263
417
  },
264
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";
265
420
  const MANAGE_TESTCASE_TOOL = {
266
421
  name: "manage_testcase",
267
422
  description: "IMPORTANT: Before creating/updating, call get_project_structure(projectId) to discover folder IDs and allowed field values.\n\n" +
268
- "Create, bulk create, update, or delete test cases.\n\n" +
423
+ "Create, update or delete test cases and their test steps.\n\n" +
269
424
  "ACTIONS:\n" +
270
- " create - Create a single test case. Params: title (required), summary, priority, folder_id, labels, test_steps, precondition, status, automated, test_data, expected_result, platform, environment, features, owner_id, custom_fields\n" +
271
- " create_bulk - Create multiple test cases. Params: testcases (JSON array)\n" +
272
- " update - Update one field for test case(s). Params: ids, field, value (all required)\n" +
273
- " delete - Delete test case(s). Params: ids (required)\n\n" +
274
- "VALID UPDATE FIELDS: title, summary, precondition, priority, status, automated, test_data, expected_result, labels, features, platform, environment, custom_fields, owner_id, folder_id\n\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" +
275
435
  "EXAMPLES:\n" +
276
- " Create: { action: 'create', title: 'Verify login' }\n" +
436
+ " Create: { action: 'create', title: 'Verify login', test_steps: '[{\"description\":\"Open login page\",\"expected_result\":\"Form shown\"}]' }\n" +
277
437
  " Update: { action: 'update', ids: 'TEST-1', field: 'priority', value: '<value from get_project_structure>' }\n" +
278
438
  " Delete: { action: 'delete', ids: 'TEST-1,TEST-2' }",
439
+ annotations: { destructiveHint: true },
279
440
  inputSchema: {
280
441
  type: "object",
281
442
  properties: {
282
443
  projectId: { type: "string", description: "Project ID" },
283
444
  action: {
284
445
  type: "string",
285
- enum: ["create", "create_bulk", "update", "delete"],
446
+ enum: ["create", "create_bulk", "update", "delete", "add_steps", "update_step", "delete_steps", "reorder_steps"],
286
447
  description: "Action to perform",
287
448
  },
288
449
  // create params
@@ -292,14 +453,14 @@ const MANAGE_TESTCASE_TOOL = {
292
453
  type: "string",
293
454
  description: "[create] Priority. Values are project-specific — call get_project_structure(projectId, include='field_options') first to discover allowed values.",
294
455
  },
295
- folder_id: { type: "string", description: "[create] Folder ID to place the test case in" },
456
+ folder_id: { type: "number", description: "[create] Folder ID to place the test case in" },
296
457
  labels: {
297
458
  type: "string",
298
459
  description: "[create] Comma-separated label names",
299
460
  },
300
461
  test_steps: {
301
462
  type: "string",
302
- description: '[create] JSON array of test steps: [{"description":"...","test_data":"...","expected_result":"..."}]',
463
+ description: '[create/add_steps] JSON array of test steps: [{"description":"...","test_data":"...","expected_result":"..."}]. Every step needs a description.',
303
464
  },
304
465
  precondition: { type: "string", description: "[create] Preconditions for the test case" },
305
466
  status: {
@@ -310,15 +471,15 @@ const MANAGE_TESTCASE_TOOL = {
310
471
  type: "string",
311
472
  description: "[create] Automation status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
312
473
  },
313
- test_data: { type: "string", description: "[create] Test data for the test case" },
314
- expected_result: { type: "string", description: "[create] Expected result for the test case" },
474
+ test_data: { type: "string", description: "[create/update_step] Test data" },
475
+ expected_result: { type: "string", description: "[create/update_step] Expected result" },
315
476
  platform: {
316
477
  type: "string",
317
478
  description: "[create] Platform. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
318
479
  },
319
480
  environment: { type: "string", description: "[create] Comma-separated environment values" },
320
481
  features: { type: "string", description: "[create] Comma-separated feature/component names" },
321
- owner_id: { type: "string", description: "[create] Owner user ID" },
482
+ owner_id: { type: "string", description: "[create] Owner user ID (a project member)" },
322
483
  custom_fields: {
323
484
  type: "string",
324
485
  description: '[create] JSON object of custom field values (e.g. {"custom1": "value", "custom2": ["val1","val2"]})',
@@ -326,7 +487,8 @@ const MANAGE_TESTCASE_TOOL = {
326
487
  // create_bulk params
327
488
  testcases: {
328
489
  type: "string",
329
- description: "[create_bulk] JSON array of test cases, each with title, summary, priority, folder_id, test_steps",
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":"..."}]}]',
330
492
  },
331
493
  // update/delete params
332
494
  ids: {
@@ -335,9 +497,15 @@ const MANAGE_TESTCASE_TOOL = {
335
497
  },
336
498
  field: {
337
499
  type: "string",
338
- description: "[update] Field to update. Valid: title, summary, precondition, priority, status, automated, test_data, expected_result, labels, features, platform, environment, custom_fields, owner_id, folder_id. For priority/status/automated/platform/environment, call get_project_structure(include='field_options') to get allowed values.",
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.",
339
502
  },
340
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" },
341
509
  },
342
510
  required: ["projectId", "action"],
343
511
  },
@@ -348,8 +516,10 @@ const MANAGE_FOLDER_TOOL = {
348
516
  "ACTIONS:\n" +
349
517
  " create - Create a folder. Params: name (required), section, parentId\n" +
350
518
  " rename - Rename a folder. Params: folderId (required), name (required)\n" +
351
- " move - Move a folder. Params: folderId (required), parentId (null for root)\n" +
352
- " delete - Delete a folder. Params: folderId (required)",
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 },
353
523
  inputSchema: {
354
524
  type: "object",
355
525
  properties: {
@@ -380,15 +550,16 @@ const MANAGE_FOLDER_TOOL = {
380
550
  const SEARCH_TEST_CYCLES_TOOL = {
381
551
  name: "search_test_cycles",
382
552
  description: "List and search test cycles in a project with execution progress.",
553
+ annotations: { readOnlyHint: true },
383
554
  inputSchema: {
384
555
  type: "object",
385
556
  properties: {
386
557
  projectId: { type: "string", description: "Project ID" },
387
558
  search: { type: "string", description: "Search by title" },
388
559
  folderId: { type: "number", description: "Filter by folder ID" },
389
- status: { type: "string", description: "Filter by status (open, closed, etc.)" },
560
+ status: { type: "string", enum: ["open", "in_progress", "completed"], description: "Filter by status" },
390
561
  page: { type: "number", description: "Page number (default: 1)" },
391
- limit: { type: "number", description: "Items per page (default: 20)" },
562
+ limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
392
563
  },
393
564
  required: ["projectId"],
394
565
  },
@@ -406,6 +577,7 @@ const MANAGE_TEST_CYCLE_TOOL = {
406
577
  " unlink_testcases - Unlink test cases from cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
407
578
  " assign_testcases - Assign test cases to user. Params: cycleId (required), testcase_ids (required), assignee_id (required)\n\n" +
408
579
  "VALID STATUSES: open, in_progress, completed",
580
+ annotations: { destructiveHint: true },
409
581
  inputSchema: {
410
582
  type: "object",
411
583
  properties: {
@@ -434,7 +606,8 @@ const MANAGE_TEST_CYCLE_TOOL = {
434
606
  summary: { type: "string", description: "[create/update] Description" },
435
607
  status: {
436
608
  type: "string",
437
- description: "[create/update] Status: open, in_progress, completed",
609
+ enum: ["open", "in_progress", "completed"],
610
+ description: "[create/update] Status",
438
611
  },
439
612
  planned_start_date: {
440
613
  type: "string",
@@ -474,7 +647,8 @@ const EXECUTE_TESTS_TOOL = {
474
647
  name: "execute_tests",
475
648
  description: "Record test execution results for test cases in a test cycle. " +
476
649
  "Pass a single result or an array of results. " +
477
- "Valid execution statuses: pass, fail, blocked, not_executed.",
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).",
478
652
  inputSchema: {
479
653
  type: "object",
480
654
  properties: {
@@ -485,6 +659,12 @@ const EXECUTE_TESTS_TOOL = {
485
659
  description: 'JSON array of results: [{"testcase_id":"TEST-1","execution_status":"pass","actual_result":"Worked","environment":"staging"}]. ' +
486
660
  "Each object must have testcase_id and execution_status. actual_result and environment are optional.",
487
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
+ },
488
668
  },
489
669
  required: ["projectId", "cycleId", "results"],
490
670
  },
@@ -501,7 +681,9 @@ const MANAGE_TEST_PLAN_TOOL = {
501
681
  " link_cycles - Link cycles to plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
502
682
  " unlink_cycles - Unlink cycles from plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
503
683
  " get_cycles - List cycles in plan. Params: planId (required), search, page, limit\n" +
504
- " get_testcases - List test cases in plan. Params: planId (required), search, page, limit",
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 },
505
687
  inputSchema: {
506
688
  type: "object",
507
689
  properties: {
@@ -529,7 +711,7 @@ const MANAGE_TEST_PLAN_TOOL = {
529
711
  // create/update fields
530
712
  title: { type: "string", description: "[create/update] Plan title" },
531
713
  summary: { type: "string", description: "[create/update] Description" },
532
- status: { type: "string", description: "[create/update] Status" },
714
+ status: { type: "string", enum: ["open", "in_progress", "completed"], description: "[create/update] Status" },
533
715
  planned_start_date: {
534
716
  type: "string",
535
717
  description: "[create/update] Start date (YYYY-MM-DD)",
@@ -559,7 +741,8 @@ const MANAGE_TEST_PLAN_TOOL = {
559
741
  };
560
742
  const GET_REPORT_TOOL = {
561
743
  name: "get_report",
562
- description: "Get reports and analytics for a project. Covers execution, coverage, trends, team, defects, and AI insights. " +
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). " +
563
746
  "Available report types: execution_summary, execution_by_cycle, execution_by_tester, execution_by_priority, " +
564
747
  "execution_by_environment, execution_by_folder, requirement_coverage, traceability_matrix, " +
565
748
  "failed_requirements, uncovered_requirements, testcase_coverage, unlinked_testcases, " +
@@ -571,6 +754,7 @@ const GET_REPORT_TOOL = {
571
754
  "testcase_quality, predictive_failure, smart_prioritization, tester_effectiveness, " +
572
755
  "stale_tests, defect_hotspots, cycle_health, suite_optimization, " +
573
756
  "requirement_risk_matrix, execution_velocity, project_health.",
757
+ annotations: { readOnlyHint: true },
574
758
  inputSchema: {
575
759
  type: "object",
576
760
  properties: {
@@ -579,14 +763,15 @@ const GET_REPORT_TOOL = {
579
763
  type: "string",
580
764
  description: "Report type (see description for full list)",
581
765
  },
582
- test_cycle_ids: {
766
+ test_cycle_id: {
583
767
  type: "string",
584
- description: "Comma-separated test cycle IDs to filter by",
768
+ description: "Only this test cycle (one ID, e.g. TCYCLE-3)",
585
769
  },
586
- test_plan_ids: {
770
+ test_plan_id: {
587
771
  type: "string",
588
- description: "Comma-separated test plan IDs to filter by",
772
+ description: "Only this test plan (one ID, e.g. TPLAN-2)",
589
773
  },
774
+ folder_id: { type: "number", description: "Only this folder" },
590
775
  startDate: { type: "string", description: "Start date filter (YYYY-MM-DD)" },
591
776
  endDate: { type: "string", description: "End date filter (YYYY-MM-DD)" },
592
777
  granularity: {
@@ -663,7 +848,6 @@ const REPORT_TYPE_MAP = {
663
848
  async function handleListProjects(args) {
664
849
  const data = await callApi("get", "projects/user/my-projects", {
665
850
  query: {
666
- search: p(args, "search"),
667
851
  page: p(args, "page"),
668
852
  limit: p(args, "limit"),
669
853
  },
@@ -717,10 +901,17 @@ async function handleSearchTestcases(args) {
717
901
  if (!projectId)
718
902
  throw new Error("projectId is required");
719
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
+ });
720
911
  const data = await callApi("get", `projects/testcases/get-testcase/${projectId}`, {
721
912
  query: {
722
913
  search: p(args, "search"),
723
- filters: p(args, "filters"),
914
+ filters,
724
915
  page: p(args, "page"),
725
916
  limit: p(args, "limit"),
726
917
  sortBy: p(args, "sortBy", "sort_by"),
@@ -767,158 +958,71 @@ async function handleManageTestcase(args) {
767
958
  const title = p(args, "title", "name");
768
959
  if (!title)
769
960
  throw new Error("title is required for create action");
770
- const testcaseData = { title };
771
- if (p(args, "summary", "description"))
772
- testcaseData.summary = p(args, "summary", "description");
773
- if (p(args, "priority"))
774
- testcaseData.priority = await coerceFieldValue(projectId, "priority", p(args, "priority"));
775
- const rawFolderId = p(args, "folder_id", "folderId");
776
- if (rawFolderId) {
777
- const numFolderId = Number(rawFolderId);
778
- testcaseData.folder_id = isNaN(numFolderId) ? rawFolderId : numFolderId;
779
- }
780
- const labelsRaw = p(args, "labels");
781
- if (labelsRaw) {
782
- testcaseData.labels =
783
- typeof labelsRaw === "string"
784
- ? labelsRaw.split(",").map((l) => l.trim()).filter(Boolean)
785
- : labelsRaw;
786
- }
787
- if (p(args, "precondition"))
788
- testcaseData.precondition = p(args, "precondition");
789
- if (p(args, "status"))
790
- testcaseData.status = await coerceFieldValue(projectId, "status", p(args, "status"));
791
- if (p(args, "automated"))
792
- testcaseData.automated = await coerceFieldValue(projectId, "automated", p(args, "automated"));
793
- if (p(args, "test_data"))
794
- testcaseData.test_data = p(args, "test_data");
795
- if (p(args, "expected_result"))
796
- testcaseData.expected_result = p(args, "expected_result");
797
- if (p(args, "platform"))
798
- testcaseData.platform = await coerceFieldValue(projectId, "platform", p(args, "platform"));
799
- const featuresRaw = p(args, "features");
800
- if (featuresRaw) {
801
- testcaseData.features = typeof featuresRaw === "string"
802
- ? featuresRaw.split(",").map((s) => s.trim())
803
- : featuresRaw;
804
- }
805
- const envRaw = p(args, "environment");
806
- if (envRaw) {
807
- testcaseData.environment = typeof envRaw === "string"
808
- ? envRaw.split(",").map((s) => s.trim())
809
- : envRaw;
810
- }
811
- const ownerId = p(args, "owner_id", "ownerId");
812
- if (ownerId) {
813
- const num = Number(ownerId);
814
- testcaseData.owner_id = isNaN(num) ? ownerId : num;
815
- }
816
- const customFieldsRaw = p(args, "custom_fields", "customFields");
817
- if (customFieldsRaw) {
818
- const parsed = safeJsonParse(customFieldsRaw, "custom_fields");
819
- for (const key of Object.keys(parsed)) {
820
- if (!Array.isArray(parsed[key])) {
821
- parsed[key] = [parsed[key]];
822
- }
823
- }
824
- testcaseData.custom_fields = parsed;
825
- }
826
- // Parse test_steps
827
- const stepsRaw = p(args, "test_steps", "testSteps", "steps");
828
- const testSteps = stepsRaw ? safeJsonParse(stepsRaw, "test_steps") : null;
829
- // Step 1: Create testcase
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 });
830
964
  const createResult = await callApi("post", `projects/testcases/create-testcase/${projectId}`, { body: testcaseData });
831
- // Step 2: Add test steps if provided
832
- const created = createResult?.data || createResult;
833
- const tcId = created?.id || created?.testcaseId;
834
- if (testSteps && Array.isArray(testSteps) && testSteps.length > 0 && tcId) {
835
- // Backend endpoint accepts a single step per call, so loop through each step
836
- for (const s of testSteps) {
837
- const step = {
838
- description: s.description || s.step,
839
- test_data: s.test_data,
840
- expected_result: s.expected_result,
841
- };
842
- await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, {
843
- body: step,
844
- });
845
- }
846
- }
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
+ ];
847
969
  if (tcId) {
848
- const created2 = createResult?.data || createResult;
849
- if (created2 && typeof created2 === "object") {
850
- created2.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${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
+ }
851
978
  }
852
979
  }
853
- return jsonResponseWithHints(createResult, [
854
- "To update fields, use manage_testcase(action='update', ids='<id>', field='<name>', value='<value>')",
855
- ]);
980
+ return jsonResponseWithHints(createResult, hints);
856
981
  }
857
982
  case "create_bulk": {
858
983
  const rawTestcases = p(args, "testcases", "test_cases", "items");
859
984
  if (!rawTestcases)
860
985
  throw new Error("testcases is required for create_bulk action");
861
986
  const testcases = safeJsonParse(rawTestcases, "testcases");
862
- // Separate test_steps for post-creation
863
- const withSteps = testcases.map((tc) => {
864
- const normalized = { title: tc.title || tc.name };
865
- if (tc.summary || tc.description)
866
- normalized.summary = tc.summary || tc.description;
867
- if (tc.priority)
868
- normalized.priority = tc.priority;
869
- const rawFolderId = tc.folder_id || tc.folderId;
870
- if (rawFolderId) {
871
- const numFolderId = Number(rawFolderId);
872
- normalized.folder_id = isNaN(numFolderId) ? rawFolderId : numFolderId;
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
+ });
873
1000
  }
874
- if (tc.labels) {
875
- normalized.labels = typeof tc.labels === "string"
876
- ? tc.labels.split(",").map((l) => l.trim()).filter(Boolean)
877
- : tc.labels;
1001
+ catch (error) {
1002
+ throw new Error(`testcases[${i}]: ${error.message}`);
878
1003
  }
879
- if (tc.precondition)
880
- normalized.precondition = tc.precondition;
881
- if (tc.status)
882
- normalized.status = tc.status;
883
- if (tc.automated)
884
- normalized.automated = tc.automated;
885
- return {
886
- testcase: normalized,
887
- test_steps: tc.test_steps || tc.testSteps || tc.steps,
888
- };
889
- });
890
- const cleanTestcases = withSteps.map((t) => t.testcase);
891
- const bulkResult = await callApi("post", `projects/testcases/create-bulk-testcase/${projectId}`, { body: { testcases: cleanTestcases } });
1004
+ }
1005
+ const bulkResult = await callApi("post", `projects/testcases/create-bulk-testcase/${projectId}`, { body: { testcases: items.map((t) => t.testcase) } });
892
1006
  // Add test steps for each created testcase
893
1007
  const results = bulkResult?.results || [];
894
1008
  let stepsCreated = 0;
895
- for (let i = 0; i < results.length; i++) {
896
- const r = results[i];
897
- const steps = withSteps[i]?.test_steps;
898
- if (r.status === "success" && steps?.length) {
899
- const tcId = r.testcaseId || r.id;
900
- if (tcId) {
901
- try {
902
- // Backend endpoint accepts a single step per call, so loop through each step
903
- for (const s of steps) {
904
- const step = {
905
- description: s.description || s.step,
906
- test_data: s.test_data,
907
- expected_result: s.expected_result,
908
- };
909
- await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, {
910
- body: step,
911
- });
912
- }
913
- stepsCreated++;
914
- }
915
- catch {
916
- // Non-fatal: steps failed but testcase was created
917
- }
918
- }
919
- }
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;
920
1019
  }
921
- return jsonResponse({ ...bulkResult, steps_created_for: stepsCreated });
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
+ : []);
922
1026
  }
923
1027
  case "update": {
924
1028
  const ids = p(args, "ids", "testcase_ids", "testcaseIds");
@@ -930,7 +1034,10 @@ async function handleManageTestcase(args) {
930
1034
  throw new Error("field is required for update action");
931
1035
  if (value === undefined)
932
1036
  throw new Error("value is required for update action");
933
- const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
1037
+ if (!TESTCASE_FIELDS.split(", ").includes(field)) {
1038
+ throw new Error(`Invalid field: ${field}. Valid fields: ${TESTCASE_FIELDS}`);
1039
+ }
1040
+ const idsStr = toIdList(ids);
934
1041
  // Coerce field values for project-specific fields
935
1042
  const coercibleFields = ["priority", "status", "automated", "platform"];
936
1043
  // Parse value for fields that expect non-string types
@@ -964,14 +1071,63 @@ async function handleManageTestcase(args) {
964
1071
  const ids = p(args, "ids", "testcase_ids", "testcaseIds");
965
1072
  if (!ids)
966
1073
  throw new Error("ids is required for delete action");
967
- const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
968
1074
  const data = await callApi("delete", `projects/testcases/delete-testcase/${projectId}`, {
969
- query: { ids: idsStr },
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 },
970
1126
  });
971
1127
  return jsonResponse(data);
972
1128
  }
973
1129
  default:
974
- throw new Error(`Unknown action: ${action}. Use: create, create_bulk, update, delete`);
1130
+ throw new Error(`Unknown action: ${action}. Use: create, create_bulk, update, delete, add_steps, update_step, delete_steps, reorder_steps`);
975
1131
  }
976
1132
  }
977
1133
  // 6. manage_folder
@@ -998,7 +1154,7 @@ async function handleManageFolder(args) {
998
1154
  };
999
1155
  const parentId = p(args, "parentId", "parent_id");
1000
1156
  if (parentId)
1001
- body.parentId = parentId;
1157
+ body.parentId = Number(parentId);
1002
1158
  const data = await callApi("post", "folders/create-folders", { body, skipOrgId: true });
1003
1159
  return jsonResponse(data);
1004
1160
  }
@@ -1022,9 +1178,12 @@ async function handleManageFolder(args) {
1022
1178
  throw new Error("folderId is required for move action");
1023
1179
  validateId(folderId, "folderId");
1024
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");
1025
1184
  const data = await callApi("patch", `folders/${folderId}/move`, {
1026
1185
  query: { projectId, section },
1027
- body: { parentId: parentId ?? null },
1186
+ body: { newParentId },
1028
1187
  });
1029
1188
  return jsonResponse(data);
1030
1189
  }
@@ -1048,11 +1207,15 @@ async function handleSearchTestCycles(args) {
1048
1207
  if (!projectId)
1049
1208
  throw new Error("projectId is required");
1050
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
+ });
1051
1215
  const data = await callApi("get", `projects/test-cycles/get-test-cycles/${projectId}`, {
1052
1216
  query: {
1053
1217
  search: p(args, "search"),
1054
- folderId: p(args, "folderId", "folder_id"),
1055
- status: p(args, "status"),
1218
+ filters,
1056
1219
  page: p(args, "page"),
1057
1220
  limit: p(args, "limit"),
1058
1221
  },
@@ -1060,8 +1223,9 @@ async function handleSearchTestCycles(args) {
1060
1223
  const baseUrl = getFrontendBaseUrl();
1061
1224
  if (Array.isArray(data?.data)) {
1062
1225
  data.data.forEach((cycle) => {
1063
- if (cycle.id)
1064
- cycle.url = `${baseUrl}/${projectId}/testcycles/${cycle.id}`;
1226
+ const id = cycle.test_cycle_id || cycle.id;
1227
+ if (id)
1228
+ cycle.url = `${baseUrl}/${projectId}/testcycles/${id}`;
1065
1229
  });
1066
1230
  }
1067
1231
  return jsonResponse(data);
@@ -1090,7 +1254,7 @@ async function handleManageTestCycle(args) {
1090
1254
  if (p(args, "planned_end_date", "plannedEndDate"))
1091
1255
  body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
1092
1256
  if (p(args, "folder_id", "folderId"))
1093
- body.folder_id = p(args, "folder_id", "folderId");
1257
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
1094
1258
  const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
1095
1259
  return jsonResponse(data);
1096
1260
  }
@@ -1110,7 +1274,7 @@ async function handleManageTestCycle(args) {
1110
1274
  if (p(args, "planned_end_date", "plannedEndDate"))
1111
1275
  body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
1112
1276
  if (p(args, "folder_id", "folderId"))
1113
- body.folder_id = p(args, "folder_id", "folderId");
1277
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
1114
1278
  const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
1115
1279
  return jsonResponse(data);
1116
1280
  }
@@ -1207,40 +1371,54 @@ async function handleExecuteTests(args) {
1207
1371
  if (!Array.isArray(results) || results.length === 0) {
1208
1372
  throw new Error("results must be a non-empty array");
1209
1373
  }
1210
- // Validate all results have required fields
1211
- for (const r of results) {
1212
- if (!r.testcase_id && !r.testcaseId) {
1213
- throw new Error("testcase_id is required in each result");
1214
- }
1215
- if (!r.execution_status && !r.executionStatus) {
1216
- throw new Error("execution_status is required in each result. Valid values: pass, fail, blocked, not_executed");
1217
- }
1374
+ const mode = p(args, "mode") || "manual";
1375
+ if (mode !== "manual" && mode !== "automation") {
1376
+ throw new Error("mode must be 'manual' or 'automation'");
1218
1377
  }
1219
- // Single result → use PATCH update-execution endpoint
1220
- if (results.length === 1) {
1221
- const r = results[0];
1378
+ // Validate all results before recording any
1379
+ const normalized = results.map((r, i) => {
1222
1380
  const testcaseId = r.testcase_id || r.testcaseId;
1223
- const body = {
1224
- execution_status: r.execution_status || r.executionStatus,
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,
1225
1390
  };
1226
- if (r.actual_result || r.actualResult)
1227
- body.actual_result = r.actual_result || r.actualResult;
1391
+ const actual = r.actual_result || r.actualResult;
1392
+ if (actual)
1393
+ out.actual_result = actual;
1228
1394
  if (r.environment)
1229
- body.environment = r.environment;
1230
- const data = await callApi("patch", `projects/test-cycles/testcases/update-execution/${projectId}/${cycleId}/${testcaseId}`, { body });
1231
- return jsonResponse(data);
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
+ : []);
1232
1404
  }
1233
- // Multiple results → use POST execute-bulk endpoint
1234
- const bulkResults = results.map((r) => ({
1235
- testcase_id: r.testcase_id || r.testcaseId,
1236
- execution_status: r.execution_status || r.executionStatus,
1237
- ...(r.actual_result || r.actualResult
1238
- ? { actual_result: r.actual_result || r.actualResult }
1239
- : {}),
1240
- ...(r.environment ? { environment: r.environment } : {}),
1241
- }));
1242
- const data = await callApi("post", `projects/test-cycles/execute-bulk/${projectId}/${cycleId}`, { body: { results: bulkResults } });
1243
- return jsonResponse(data);
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)."] : []);
1244
1422
  }
1245
1423
  // 10. manage_test_plan
1246
1424
  async function handleManageTestPlan(args) {
@@ -1266,7 +1444,7 @@ async function handleManageTestPlan(args) {
1266
1444
  if (p(args, "planned_end_date", "plannedEndDate"))
1267
1445
  body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
1268
1446
  if (p(args, "folder_id", "folderId"))
1269
- body.folder_id = p(args, "folder_id", "folderId");
1447
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
1270
1448
  const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
1271
1449
  return jsonResponse(data);
1272
1450
  }
@@ -1286,7 +1464,7 @@ async function handleManageTestPlan(args) {
1286
1464
  if (p(args, "planned_end_date", "plannedEndDate"))
1287
1465
  body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
1288
1466
  if (p(args, "folder_id", "folderId"))
1289
- body.folder_id = p(args, "folder_id", "folderId");
1467
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
1290
1468
  const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
1291
1469
  return jsonResponse(data);
1292
1470
  }
@@ -1309,8 +1487,9 @@ async function handleManageTestPlan(args) {
1309
1487
  const baseUrl = getFrontendBaseUrl();
1310
1488
  if (Array.isArray(data?.data)) {
1311
1489
  data.data.forEach((plan) => {
1312
- if (plan.id)
1313
- plan.url = `${baseUrl}/${projectId}/testplans/${plan.id}`;
1490
+ const id = plan.test_plan_id || plan.id;
1491
+ if (id)
1492
+ plan.url = `${baseUrl}/${projectId}/testplans/${id}`;
1314
1493
  });
1315
1494
  }
1316
1495
  return jsonResponse(data);
@@ -1396,15 +1575,26 @@ async function handleGetReport(args) {
1396
1575
  throw new Error(`Unknown report_type: ${reportType}. Valid types: ${Object.keys(REPORT_TYPE_MAP).join(", ")}`);
1397
1576
  }
1398
1577
  const query = {};
1399
- // Common filters
1400
- if (p(args, "test_cycle_ids", "testCycleIds", "testCycleId"))
1401
- query.testCycleId = p(args, "test_cycle_ids", "testCycleIds", "testCycleId");
1402
- if (p(args, "test_plan_ids", "testPlanIds", "testPlanId"))
1403
- query.testPlanId = p(args, "test_plan_ids", "testPlanIds", "testPlanId");
1404
- if (p(args, "startDate", "start_date"))
1405
- query.startDate = p(args, "startDate", "start_date");
1406
- if (p(args, "endDate", "end_date"))
1407
- query.endDate = p(args, "endDate", "end_date");
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;
1408
1598
  if (p(args, "granularity"))
1409
1599
  query.granularity = p(args, "granularity");
1410
1600
  // cycle_comparison specific
@@ -1418,7 +1608,7 @@ async function handleGetReport(args) {
1418
1608
  // ═══════════════════════════════════════════════════════════════════════════════
1419
1609
  // SERVER SETUP
1420
1610
  // ═══════════════════════════════════════════════════════════════════════════════
1421
- const server = new Server({ name: "testkase-mcp-server", version: "2.2.0" }, { capabilities: { tools: {} } });
1611
+ const server = new Server({ name: "testkase-mcp-server", version: VERSION }, { capabilities: { tools: {} } });
1422
1612
  // List all 11 tools
1423
1613
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
1424
1614
  tools: [
@@ -1479,7 +1669,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1479
1669
  async function main() {
1480
1670
  const transport = new StdioServerTransport();
1481
1671
  await server.connect(transport);
1482
- console.error("TestKase MCP Server v2.2.0 running on stdio (11 tools)");
1672
+ console.error(`TestKase MCP Server v${VERSION} running on stdio (11 tools) → ${config.apiBaseUrl}`);
1483
1673
  }
1484
1674
  main().catch((error) => {
1485
1675
  console.error("Fatal error in main():", error);