@testkase/mcp-server 2.1.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,9 +4,31 @@ 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;
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
+ }
10
32
  // ─── Auth helpers (reused from v1) ───────────────────────────────────────────
11
33
  function validateAuth() {
12
34
  if (!config.patToken) {
@@ -27,11 +49,13 @@ function getApiHeaders() {
27
49
  // ─── Cached org ID (auto-resolved from PAT, never a tool param) ─────────────
28
50
  let cachedOrganizationId = null;
29
51
  async function getOrganizationId() {
52
+ if (config.organizationId && !isNaN(config.organizationId))
53
+ return config.organizationId;
30
54
  if (cachedOrganizationId !== null)
31
55
  return cachedOrganizationId;
32
56
  validateAuth();
33
57
  try {
34
- 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 });
35
59
  const orgData = response.data?.data || response.data;
36
60
  if (orgData?.organization_id) {
37
61
  cachedOrganizationId = orgData.organization_id;
@@ -42,7 +66,10 @@ async function getOrganizationId() {
42
66
  catch (error) {
43
67
  if (error instanceof Error && error.message.includes("Could not determine"))
44
68
  throw error;
45
- 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.`);
46
73
  }
47
74
  }
48
75
  // ─── API call helper ─────────────────────────────────────────────────────────
@@ -65,9 +92,16 @@ async function callApi(method, path, options = {}) {
65
92
  method,
66
93
  url,
67
94
  headers: getApiHeaders(),
95
+ timeout: REQUEST_TIMEOUT_MS,
68
96
  ...(options.body !== undefined && { data: options.body }),
69
97
  });
70
- 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;
71
105
  }
72
106
  // ─── Error handler (reused) ──────────────────────────────────────────────────
73
107
  function handleApiError(error) {
@@ -76,7 +110,15 @@ function handleApiError(error) {
76
110
  let msg = `API request failed: ${e.message}`;
77
111
  if (e.response) {
78
112
  msg += `\nStatus: ${e.response.status}`;
79
- msg += `\nResponse: ${JSON.stringify(e.response.data)}`;
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
+ }
80
122
  }
81
123
  return new Error(msg);
82
124
  }
@@ -88,6 +130,199 @@ function jsonResponse(data) {
88
130
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
89
131
  };
90
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
+ }
91
326
  // ─── Param normalizer (handles camelCase / snake_case) ───────────────────────
92
327
  function p(args, ...names) {
93
328
  for (const n of names) {
@@ -96,6 +331,13 @@ function p(args, ...names) {
96
331
  }
97
332
  return undefined;
98
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
+ }
99
341
  // ═══════════════════════════════════════════════════════════════════════════════
100
342
  // TOOL DEFINITIONS (11 tools)
101
343
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -103,20 +345,21 @@ const LIST_PROJECTS_TOOL = {
103
345
  name: "list_projects",
104
346
  description: "List all projects the authenticated user has access to. " +
105
347
  "Use this first to find a projectId, then pass it to other tools.",
348
+ annotations: { readOnlyHint: true },
106
349
  inputSchema: {
107
350
  type: "object",
108
351
  properties: {
109
- search: { type: "string", description: "Search by project name or ID" },
110
352
  page: { type: "number", description: "Page number (default: 1)" },
111
- limit: { type: "number", description: "Items per page (default: 20)" },
353
+ limit: { type: "number", description: "Items per page (default: 12)" },
112
354
  },
113
355
  required: [],
114
356
  },
115
357
  };
116
358
  const GET_PROJECT_STRUCTURE_TOOL = {
117
359
  name: "get_project_structure",
118
- description: "Get the structure of a project: folders, labels, and/or team members. " +
119
- "Useful to discover folder IDs, label names, and member IDs before creating or updating items.",
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 },
120
363
  inputSchema: {
121
364
  type: "object",
122
365
  properties: {
@@ -128,7 +371,7 @@ const GET_PROJECT_STRUCTURE_TOOL = {
128
371
  },
129
372
  include: {
130
373
  type: "string",
131
- description: "Comma-separated list of what to include: folders,labels,members (default: all)",
374
+ description: "Comma-separated list of what to include: folders,labels,members,field_options (default: all)",
132
375
  },
133
376
  },
134
377
  required: ["projectId"],
@@ -136,18 +379,24 @@ const GET_PROJECT_STRUCTURE_TOOL = {
136
379
  };
137
380
  const SEARCH_TESTCASES_TOOL = {
138
381
  name: "search_testcases",
139
- 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 },
140
384
  inputSchema: {
141
385
  type: "object",
142
386
  properties: {
143
387
  projectId: { type: "string", description: "Project ID" },
144
- 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
+ },
145
393
  filters: {
146
394
  type: "string",
147
- 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".',
148
397
  },
149
398
  page: { type: "number", description: "Page number (default: 1)" },
150
- limit: { type: "number", description: "Items per page (default: 20)" },
399
+ limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
151
400
  sortBy: { type: "string", description: "Field to sort by (e.g. created_at, title)" },
152
401
  sortOrder: { type: "string", enum: ["asc", "desc"], description: "Sort direction" },
153
402
  },
@@ -156,7 +405,8 @@ const SEARCH_TESTCASES_TOOL = {
156
405
  };
157
406
  const GET_TESTCASE_TOOL = {
158
407
  name: "get_testcase",
159
- 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 },
160
410
  inputSchema: {
161
411
  type: "object",
162
412
  properties: {
@@ -166,17 +416,34 @@ const GET_TESTCASE_TOOL = {
166
416
  required: ["projectId", "testcaseId"],
167
417
  },
168
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";
169
420
  const MANAGE_TESTCASE_TOOL = {
170
421
  name: "manage_testcase",
171
- description: "Create, bulk create, update, or delete test cases. " +
172
- "Set 'action' to: create, create_bulk, update, or delete.",
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 },
173
440
  inputSchema: {
174
441
  type: "object",
175
442
  properties: {
176
443
  projectId: { type: "string", description: "Project ID" },
177
444
  action: {
178
445
  type: "string",
179
- enum: ["create", "create_bulk", "update", "delete"],
446
+ enum: ["create", "create_bulk", "update", "delete", "add_steps", "update_step", "delete_steps", "reorder_steps"],
180
447
  description: "Action to perform",
181
448
  },
182
449
  // create params
@@ -184,39 +451,75 @@ const MANAGE_TESTCASE_TOOL = {
184
451
  summary: { type: "string", description: "[create] Description/summary" },
185
452
  priority: {
186
453
  type: "string",
187
- description: "[create] Priority: low, medium, high, or critical",
454
+ description: "[create] Priority. Values are project-specific — call get_project_structure(projectId, include='field_options') first to discover allowed values.",
188
455
  },
189
- 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" },
190
457
  labels: {
191
458
  type: "string",
192
459
  description: "[create] Comma-separated label names",
193
460
  },
194
461
  test_steps: {
195
462
  type: "string",
196
- description: '[create] JSON array of test steps: [{"step":"...","expected_result":"..."}]',
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"]})',
197
486
  },
198
487
  // create_bulk params
199
488
  testcases: {
200
489
  type: "string",
201
- 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":"..."}]}]',
202
492
  },
203
- // update params
493
+ // update/delete params
204
494
  ids: {
205
495
  type: "string",
206
- description: "[update/delete] Comma-separated test case IDs",
496
+ description: "[update/delete] Test case IDs, comma-separated (e.g. 'TEST-1' or 'TEST-1,TEST-2')",
207
497
  },
208
498
  field: {
209
499
  type: "string",
210
- description: "[update] Field name to update (title, summary, priority, status, etc.)",
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.",
211
502
  },
212
- value: { type: "string", description: "[update] New value for the field" },
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" },
213
509
  },
214
510
  required: ["projectId", "action"],
215
511
  },
216
512
  };
217
513
  const MANAGE_FOLDER_TOOL = {
218
514
  name: "manage_folder",
219
- description: "Create, rename, move, or delete folders. Works across all sections (TESTCASE, TEST_CYCLE, TEST_PLAN).",
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 },
220
523
  inputSchema: {
221
524
  type: "object",
222
525
  properties: {
@@ -247,23 +550,34 @@ const MANAGE_FOLDER_TOOL = {
247
550
  const SEARCH_TEST_CYCLES_TOOL = {
248
551
  name: "search_test_cycles",
249
552
  description: "List and search test cycles in a project with execution progress.",
553
+ annotations: { readOnlyHint: true },
250
554
  inputSchema: {
251
555
  type: "object",
252
556
  properties: {
253
557
  projectId: { type: "string", description: "Project ID" },
254
558
  search: { type: "string", description: "Search by title" },
255
559
  folderId: { type: "number", description: "Filter by folder ID" },
256
- status: { type: "string", description: "Filter by status (open, closed, etc.)" },
560
+ status: { type: "string", enum: ["open", "in_progress", "completed"], description: "Filter by status" },
257
561
  page: { type: "number", description: "Page number (default: 1)" },
258
- limit: { type: "number", description: "Items per page (default: 20)" },
562
+ limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
259
563
  },
260
564
  required: ["projectId"],
261
565
  },
262
566
  };
263
567
  const MANAGE_TEST_CYCLE_TOOL = {
264
568
  name: "manage_test_cycle",
265
- description: "Manage test cycles: create, update, delete, get details, list/link/unlink/assign test cases. " +
266
- "Set 'action' to: create, update, delete, get_details, get_testcases, link_testcases, unlink_testcases, assign_testcases.",
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 },
267
581
  inputSchema: {
268
582
  type: "object",
269
583
  properties: {
@@ -292,7 +606,8 @@ const MANAGE_TEST_CYCLE_TOOL = {
292
606
  summary: { type: "string", description: "[create/update] Description" },
293
607
  status: {
294
608
  type: "string",
295
- description: "[create/update] Status: open, closed, in progress",
609
+ enum: ["open", "in_progress", "completed"],
610
+ description: "[create/update] Status",
296
611
  },
297
612
  planned_start_date: {
298
613
  type: "string",
@@ -332,7 +647,8 @@ const EXECUTE_TESTS_TOOL = {
332
647
  name: "execute_tests",
333
648
  description: "Record test execution results for test cases in a test cycle. " +
334
649
  "Pass a single result or an array of results. " +
335
- "Valid execution statuses: pass, fail, blocked, not_executed, in_progress.",
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).",
336
652
  inputSchema: {
337
653
  type: "object",
338
654
  properties: {
@@ -343,14 +659,31 @@ const EXECUTE_TESTS_TOOL = {
343
659
  description: 'JSON array of results: [{"testcase_id":"TEST-1","execution_status":"pass","actual_result":"Worked","environment":"staging"}]. ' +
344
660
  "Each object must have testcase_id and execution_status. actual_result and environment are optional.",
345
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
+ },
346
668
  },
347
669
  required: ["projectId", "cycleId", "results"],
348
670
  },
349
671
  };
350
672
  const MANAGE_TEST_PLAN_TOOL = {
351
673
  name: "manage_test_plan",
352
- description: "Manage test plans: create, update, delete, list, get details, link/unlink cycles, get cycles, get test cases. " +
353
- "Set 'action' to: create, update, delete, list, get_details, link_cycles, unlink_cycles, get_cycles, get_testcases.",
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 },
354
687
  inputSchema: {
355
688
  type: "object",
356
689
  properties: {
@@ -378,7 +711,7 @@ const MANAGE_TEST_PLAN_TOOL = {
378
711
  // create/update fields
379
712
  title: { type: "string", description: "[create/update] Plan title" },
380
713
  summary: { type: "string", description: "[create/update] Description" },
381
- status: { type: "string", description: "[create/update] Status" },
714
+ status: { type: "string", enum: ["open", "in_progress", "completed"], description: "[create/update] Status" },
382
715
  planned_start_date: {
383
716
  type: "string",
384
717
  description: "[create/update] Start date (YYYY-MM-DD)",
@@ -408,7 +741,8 @@ const MANAGE_TEST_PLAN_TOOL = {
408
741
  };
409
742
  const GET_REPORT_TOOL = {
410
743
  name: "get_report",
411
- 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). " +
412
746
  "Available report types: execution_summary, execution_by_cycle, execution_by_tester, execution_by_priority, " +
413
747
  "execution_by_environment, execution_by_folder, requirement_coverage, traceability_matrix, " +
414
748
  "failed_requirements, uncovered_requirements, testcase_coverage, unlinked_testcases, " +
@@ -420,6 +754,7 @@ const GET_REPORT_TOOL = {
420
754
  "testcase_quality, predictive_failure, smart_prioritization, tester_effectiveness, " +
421
755
  "stale_tests, defect_hotspots, cycle_health, suite_optimization, " +
422
756
  "requirement_risk_matrix, execution_velocity, project_health.",
757
+ annotations: { readOnlyHint: true },
423
758
  inputSchema: {
424
759
  type: "object",
425
760
  properties: {
@@ -428,14 +763,15 @@ const GET_REPORT_TOOL = {
428
763
  type: "string",
429
764
  description: "Report type (see description for full list)",
430
765
  },
431
- test_cycle_ids: {
766
+ test_cycle_id: {
432
767
  type: "string",
433
- description: "Comma-separated test cycle IDs to filter by",
768
+ description: "Only this test cycle (one ID, e.g. TCYCLE-3)",
434
769
  },
435
- test_plan_ids: {
770
+ test_plan_id: {
436
771
  type: "string",
437
- description: "Comma-separated test plan IDs to filter by",
772
+ description: "Only this test plan (one ID, e.g. TPLAN-2)",
438
773
  },
774
+ folder_id: { type: "number", description: "Only this folder" },
439
775
  startDate: { type: "string", description: "Start date filter (YYYY-MM-DD)" },
440
776
  endDate: { type: "string", description: "End date filter (YYYY-MM-DD)" },
441
777
  granularity: {
@@ -512,20 +848,22 @@ const REPORT_TYPE_MAP = {
512
848
  async function handleListProjects(args) {
513
849
  const data = await callApi("get", "projects/user/my-projects", {
514
850
  query: {
515
- search: p(args, "search"),
516
851
  page: p(args, "page"),
517
852
  limit: p(args, "limit"),
518
853
  },
519
854
  });
520
- return jsonResponse(data);
855
+ return jsonResponseWithHints(data, [
856
+ "Next: call get_project_structure(projectId) to see folders, labels, and allowed field values.",
857
+ ]);
521
858
  }
522
859
  // 2. get_project_structure
523
860
  async function handleGetProjectStructure(args) {
524
861
  const projectId = p(args, "projectId", "project_id");
525
862
  if (!projectId)
526
863
  throw new Error("projectId is required");
864
+ validateId(projectId, "projectId");
527
865
  const section = p(args, "section") || "TESTCASE";
528
- const includeRaw = p(args, "include") || "folders,labels,members";
866
+ const includeRaw = p(args, "include") || "folders,labels,members,field_options";
529
867
  const includes = includeRaw.split(",").map((s) => s.trim().toLowerCase());
530
868
  const orgId = await getOrganizationId();
531
869
  const result = {};
@@ -547,24 +885,46 @@ async function handleGetProjectStructure(args) {
547
885
  result.members = data;
548
886
  }));
549
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
+ }
550
893
  await Promise.all(promises);
551
- return jsonResponse(result);
894
+ return jsonResponseWithHints(result, [
895
+ "Use the field_options values above when creating/updating test cases.",
896
+ ]);
552
897
  }
553
898
  // 3. search_testcases
554
899
  async function handleSearchTestcases(args) {
555
900
  const projectId = p(args, "projectId", "project_id");
556
901
  if (!projectId)
557
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
+ });
558
911
  const data = await callApi("get", `projects/testcases/get-testcase/${projectId}`, {
559
912
  query: {
560
913
  search: p(args, "search"),
561
- filters: p(args, "filters"),
914
+ filters,
562
915
  page: p(args, "page"),
563
916
  limit: p(args, "limit"),
564
917
  sortBy: p(args, "sortBy", "sort_by"),
565
918
  sortOrder: p(args, "sortOrder", "sort_order"),
566
919
  },
567
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
+ }
568
928
  return jsonResponse(data);
569
929
  }
570
930
  // 4. get_testcase
@@ -575,7 +935,12 @@ async function handleGetTestcase(args) {
575
935
  throw new Error("projectId is required");
576
936
  if (!testcaseId)
577
937
  throw new Error("testcaseId is required");
938
+ validateId(projectId, "projectId");
939
+ validateId(testcaseId, "testcaseId");
578
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
+ }
579
944
  return jsonResponse(data);
580
945
  }
581
946
  // 5. manage_testcase
@@ -586,88 +951,78 @@ async function handleManageTestcase(args) {
586
951
  throw new Error("projectId is required");
587
952
  if (!action)
588
953
  throw new Error("action is required");
954
+ validateId(projectId, "projectId");
589
955
  const orgId = await getOrganizationId();
590
956
  switch (action) {
591
957
  case "create": {
592
958
  const title = p(args, "title", "name");
593
959
  if (!title)
594
960
  throw new Error("title is required for create action");
595
- const testcaseData = { title };
596
- if (p(args, "summary", "description"))
597
- testcaseData.summary = p(args, "summary", "description");
598
- if (p(args, "priority"))
599
- testcaseData.priority = p(args, "priority");
600
- if (p(args, "folder_id", "folderId"))
601
- testcaseData.folder_id = p(args, "folder_id", "folderId");
602
- const labelsRaw = p(args, "labels");
603
- if (labelsRaw) {
604
- testcaseData.labels =
605
- typeof labelsRaw === "string"
606
- ? labelsRaw.split(",").map((l) => l.trim())
607
- : labelsRaw;
608
- }
609
- // Parse test_steps
610
- const stepsRaw = p(args, "test_steps", "testSteps", "steps");
611
- const testSteps = stepsRaw
612
- ? typeof stepsRaw === "string"
613
- ? JSON.parse(stepsRaw)
614
- : stepsRaw
615
- : null;
616
- // 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 });
617
964
  const createResult = await callApi("post", `projects/testcases/create-testcase/${projectId}`, { body: testcaseData });
618
- // Step 2: Add test steps if provided
619
- const created = createResult?.data || createResult;
620
- const tcId = created?.id || created?.testcaseId;
621
- if (testSteps && Array.isArray(testSteps) && testSteps.length > 0 && tcId) {
622
- await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, {
623
- body: { steps: testSteps },
624
- });
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
+ }
625
979
  }
626
- return jsonResponse(createResult);
980
+ return jsonResponseWithHints(createResult, hints);
627
981
  }
628
982
  case "create_bulk": {
629
983
  const rawTestcases = p(args, "testcases", "test_cases", "items");
630
984
  if (!rawTestcases)
631
985
  throw new Error("testcases is required for create_bulk action");
632
- const testcases = typeof rawTestcases === "string" ? JSON.parse(rawTestcases) : rawTestcases;
633
- // Separate test_steps for post-creation
634
- const withSteps = testcases.map((tc) => {
635
- const normalized = { title: tc.title || tc.name };
636
- if (tc.summary || tc.description)
637
- normalized.summary = tc.summary || tc.description;
638
- if (tc.priority)
639
- normalized.priority = tc.priority;
640
- if (tc.folder_id || tc.folderId)
641
- normalized.folder_id = tc.folder_id || tc.folderId;
642
- return {
643
- testcase: normalized,
644
- test_steps: tc.test_steps || tc.testSteps || tc.steps,
645
- };
646
- });
647
- const cleanTestcases = withSteps.map((t) => t.testcase);
648
- const bulkResult = await callApi("post", `projects/testcases/create-bulk-testcase/${projectId}`, { body: { testcases: cleanTestcases } });
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) } });
649
1006
  // Add test steps for each created testcase
650
1007
  const results = bulkResult?.results || [];
651
1008
  let stepsCreated = 0;
652
- for (let i = 0; i < results.length; i++) {
653
- const r = results[i];
654
- const steps = withSteps[i]?.test_steps;
655
- if (r.status === "success" && steps?.length) {
656
- const tcId = r.testcaseId || r.id;
657
- if (tcId) {
658
- try {
659
- await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, {
660
- body: { steps },
661
- });
662
- stepsCreated++;
663
- }
664
- catch {
665
- // Non-fatal: steps failed but testcase was created
666
- }
667
- }
668
- }
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;
669
1019
  }
670
- 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
+ : []);
671
1026
  }
672
1027
  case "update": {
673
1028
  const ids = p(args, "ids", "testcase_ids", "testcaseIds");
@@ -679,9 +1034,36 @@ async function handleManageTestcase(args) {
679
1034
  throw new Error("field is required for update action");
680
1035
  if (value === undefined)
681
1036
  throw new Error("value is required for update action");
682
- const idList = typeof ids === "string" ? ids.split(",").map((s) => s.trim()) : 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);
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
+ }
683
1065
  const data = await callApi("patch", `projects/testcases/update-field/${projectId}`, {
684
- body: { ids: idList, field, value },
1066
+ body: { ids: idsStr, [field]: parsedValue },
685
1067
  });
686
1068
  return jsonResponse(data);
687
1069
  }
@@ -689,14 +1071,63 @@ async function handleManageTestcase(args) {
689
1071
  const ids = p(args, "ids", "testcase_ids", "testcaseIds");
690
1072
  if (!ids)
691
1073
  throw new Error("ids is required for delete action");
692
- const idList = typeof ids === "string" ? ids.split(",").map((s) => s.trim()) : ids;
693
1074
  const data = await callApi("delete", `projects/testcases/delete-testcase/${projectId}`, {
694
- body: { ids: idList },
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 },
695
1126
  });
696
1127
  return jsonResponse(data);
697
1128
  }
698
1129
  default:
699
- 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`);
700
1131
  }
701
1132
  }
702
1133
  // 6. manage_folder
@@ -707,6 +1138,7 @@ async function handleManageFolder(args) {
707
1138
  throw new Error("projectId is required");
708
1139
  if (!action)
709
1140
  throw new Error("action is required");
1141
+ validateId(projectId, "projectId");
710
1142
  const section = p(args, "section") || "TESTCASE";
711
1143
  const orgId = await getOrganizationId();
712
1144
  switch (action) {
@@ -722,7 +1154,7 @@ async function handleManageFolder(args) {
722
1154
  };
723
1155
  const parentId = p(args, "parentId", "parent_id");
724
1156
  if (parentId)
725
- body.parentId = parentId;
1157
+ body.parentId = Number(parentId);
726
1158
  const data = await callApi("post", "folders/create-folders", { body, skipOrgId: true });
727
1159
  return jsonResponse(data);
728
1160
  }
@@ -733,6 +1165,7 @@ async function handleManageFolder(args) {
733
1165
  throw new Error("folderId is required for rename action");
734
1166
  if (!name)
735
1167
  throw new Error("name is required for rename action");
1168
+ validateId(folderId, "folderId");
736
1169
  const data = await callApi("patch", `folders/${folderId}`, {
737
1170
  query: { projectId, section },
738
1171
  body: { name },
@@ -743,10 +1176,14 @@ async function handleManageFolder(args) {
743
1176
  const folderId = p(args, "folderId", "folder_id", "id");
744
1177
  if (!folderId)
745
1178
  throw new Error("folderId is required for move action");
1179
+ validateId(folderId, "folderId");
746
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");
747
1184
  const data = await callApi("patch", `folders/${folderId}/move`, {
748
1185
  query: { projectId, section },
749
- body: { parentId: parentId ?? null },
1186
+ body: { newParentId },
750
1187
  });
751
1188
  return jsonResponse(data);
752
1189
  }
@@ -754,6 +1191,7 @@ async function handleManageFolder(args) {
754
1191
  const folderId = p(args, "folderId", "folder_id", "id");
755
1192
  if (!folderId)
756
1193
  throw new Error("folderId is required for delete action");
1194
+ validateId(folderId, "folderId");
757
1195
  const data = await callApi("delete", `folders/delete/${folderId}`, {
758
1196
  query: { projectId, section },
759
1197
  });
@@ -768,15 +1206,28 @@ async function handleSearchTestCycles(args) {
768
1206
  const projectId = p(args, "projectId", "project_id");
769
1207
  if (!projectId)
770
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
+ });
771
1215
  const data = await callApi("get", `projects/test-cycles/get-test-cycles/${projectId}`, {
772
1216
  query: {
773
1217
  search: p(args, "search"),
774
- folderId: p(args, "folderId", "folder_id"),
775
- status: p(args, "status"),
1218
+ filters,
776
1219
  page: p(args, "page"),
777
1220
  limit: p(args, "limit"),
778
1221
  },
779
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
+ }
780
1231
  return jsonResponse(data);
781
1232
  }
782
1233
  // 8. manage_test_cycle
@@ -787,6 +1238,7 @@ async function handleManageTestCycle(args) {
787
1238
  throw new Error("projectId is required");
788
1239
  if (!action)
789
1240
  throw new Error("action is required");
1241
+ validateId(projectId, "projectId");
790
1242
  switch (action) {
791
1243
  case "create": {
792
1244
  const title = p(args, "title");
@@ -802,7 +1254,7 @@ async function handleManageTestCycle(args) {
802
1254
  if (p(args, "planned_end_date", "plannedEndDate"))
803
1255
  body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
804
1256
  if (p(args, "folder_id", "folderId"))
805
- body.folder_id = p(args, "folder_id", "folderId");
1257
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
806
1258
  const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
807
1259
  return jsonResponse(data);
808
1260
  }
@@ -822,7 +1274,7 @@ async function handleManageTestCycle(args) {
822
1274
  if (p(args, "planned_end_date", "plannedEndDate"))
823
1275
  body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
824
1276
  if (p(args, "folder_id", "folderId"))
825
- body.folder_id = p(args, "folder_id", "folderId");
1277
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
826
1278
  const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
827
1279
  return jsonResponse(data);
828
1280
  }
@@ -830,21 +1282,26 @@ async function handleManageTestCycle(args) {
830
1282
  const ids = p(args, "ids", "cycleId", "cycle_id");
831
1283
  if (!ids)
832
1284
  throw new Error("ids (or cycleId) is required for delete action");
833
- const idList = typeof ids === "string" ? ids.split(",").map((s) => s.trim()) : ids;
834
- const data = await callApi("delete", `projects/test-cycles/delete-test-cycle/${projectId}`, { body: { ids: idList } });
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 } });
835
1287
  return jsonResponse(data);
836
1288
  }
837
1289
  case "get_details": {
838
1290
  const cycleId = p(args, "cycleId", "cycle_id", "id");
839
1291
  if (!cycleId)
840
1292
  throw new Error("cycleId is required for get_details action");
1293
+ validateId(cycleId, "cycleId");
841
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
+ }
842
1298
  return jsonResponse(data);
843
1299
  }
844
1300
  case "get_testcases": {
845
1301
  const cycleId = p(args, "cycleId", "cycle_id", "id");
846
1302
  if (!cycleId)
847
1303
  throw new Error("cycleId is required for get_testcases action");
1304
+ validateId(cycleId, "cycleId");
848
1305
  const data = await callApi("get", `projects/test-cycles/testcases/${projectId}/${cycleId}`, {
849
1306
  query: {
850
1307
  search: p(args, "search"),
@@ -861,8 +1318,9 @@ async function handleManageTestCycle(args) {
861
1318
  throw new Error("cycleId is required for link_testcases action");
862
1319
  if (!tcIds)
863
1320
  throw new Error("testcase_ids is required for link_testcases action");
864
- const idList = typeof tcIds === "string" ? tcIds.split(",").map((s) => s.trim()) : tcIds;
865
- const data = await callApi("post", `projects/test-cycles/testcases/link/${projectId}/${cycleId}`, { body: { testcase_ids: idList } });
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 } });
866
1324
  return jsonResponse(data);
867
1325
  }
868
1326
  case "unlink_testcases": {
@@ -872,8 +1330,9 @@ async function handleManageTestCycle(args) {
872
1330
  throw new Error("cycleId is required for unlink_testcases action");
873
1331
  if (!tcIds)
874
1332
  throw new Error("testcase_ids is required for unlink_testcases action");
875
- const idList = typeof tcIds === "string" ? tcIds.split(",").map((s) => s.trim()) : tcIds;
876
- const data = await callApi("delete", `projects/test-cycles/testcases/unlink/${projectId}/${cycleId}`, { body: { testcase_ids: idList } });
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 } });
877
1336
  return jsonResponse(data);
878
1337
  }
879
1338
  case "assign_testcases": {
@@ -886,8 +1345,9 @@ async function handleManageTestCycle(args) {
886
1345
  throw new Error("testcase_ids is required for assign_testcases action");
887
1346
  if (!assigneeId)
888
1347
  throw new Error("assignee_id is required for assign_testcases action");
889
- const idList = typeof tcIds === "string" ? tcIds.split(",").map((s) => s.trim()) : tcIds;
890
- const data = await callApi("patch", `projects/test-cycles/testcases/assign/${projectId}/${cycleId}`, { body: { testcase_ids: idList, assignee_id: assigneeId } });
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) } });
891
1351
  return jsonResponse(data);
892
1352
  }
893
1353
  default:
@@ -905,37 +1365,60 @@ async function handleExecuteTests(args) {
905
1365
  throw new Error("cycleId is required");
906
1366
  if (!resultsRaw)
907
1367
  throw new Error("results is required");
908
- const results = typeof resultsRaw === "string" ? JSON.parse(resultsRaw) : resultsRaw;
1368
+ validateId(projectId, "projectId");
1369
+ validateId(cycleId, "cycleId");
1370
+ const results = safeJsonParse(resultsRaw, "results");
909
1371
  if (!Array.isArray(results) || results.length === 0) {
910
1372
  throw new Error("results must be a non-empty array");
911
1373
  }
912
- // Single result → use PATCH update-execution endpoint
913
- if (results.length === 1) {
914
- const r = results[0];
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) => {
915
1380
  const testcaseId = r.testcase_id || r.testcaseId;
1381
+ const status = r.execution_status || r.executionStatus;
916
1382
  if (!testcaseId)
917
- throw new Error("testcase_id is required in each result");
918
- const body = {
919
- execution_status: r.execution_status || r.executionStatus,
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,
920
1390
  };
921
- if (r.actual_result || r.actualResult)
922
- body.actual_result = r.actual_result || r.actualResult;
1391
+ const actual = r.actual_result || r.actualResult;
1392
+ if (actual)
1393
+ out.actual_result = actual;
923
1394
  if (r.environment)
924
- body.environment = r.environment;
925
- const data = await callApi("patch", `projects/test-cycles/testcases/update-execution/${projectId}/${cycleId}/${testcaseId}`, { body });
926
- 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
+ : []);
927
1404
  }
928
- // Multiple results → use POST execute-bulk endpoint
929
- const bulkResults = results.map((r) => ({
930
- testcase_id: r.testcase_id || r.testcaseId,
931
- execution_status: r.execution_status || r.executionStatus,
932
- ...(r.actual_result || r.actualResult
933
- ? { actual_result: r.actual_result || r.actualResult }
934
- : {}),
935
- ...(r.environment ? { environment: r.environment } : {}),
936
- }));
937
- const data = await callApi("post", `projects/test-cycles/execute-bulk/${projectId}/${cycleId}`, { body: { results: bulkResults } });
938
- 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)."] : []);
939
1422
  }
940
1423
  // 10. manage_test_plan
941
1424
  async function handleManageTestPlan(args) {
@@ -945,6 +1428,7 @@ async function handleManageTestPlan(args) {
945
1428
  throw new Error("projectId is required");
946
1429
  if (!action)
947
1430
  throw new Error("action is required");
1431
+ validateId(projectId, "projectId");
948
1432
  switch (action) {
949
1433
  case "create": {
950
1434
  const title = p(args, "title");
@@ -960,7 +1444,7 @@ async function handleManageTestPlan(args) {
960
1444
  if (p(args, "planned_end_date", "plannedEndDate"))
961
1445
  body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
962
1446
  if (p(args, "folder_id", "folderId"))
963
- body.folder_id = p(args, "folder_id", "folderId");
1447
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
964
1448
  const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
965
1449
  return jsonResponse(data);
966
1450
  }
@@ -980,7 +1464,7 @@ async function handleManageTestPlan(args) {
980
1464
  if (p(args, "planned_end_date", "plannedEndDate"))
981
1465
  body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
982
1466
  if (p(args, "folder_id", "folderId"))
983
- body.folder_id = p(args, "folder_id", "folderId");
1467
+ body.folder_id = Number(p(args, "folder_id", "folderId"));
984
1468
  const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
985
1469
  return jsonResponse(data);
986
1470
  }
@@ -988,8 +1472,8 @@ async function handleManageTestPlan(args) {
988
1472
  const ids = p(args, "ids", "planId", "plan_id");
989
1473
  if (!ids)
990
1474
  throw new Error("ids (or planId) is required for delete action");
991
- const idList = typeof ids === "string" ? ids.split(",").map((s) => s.trim()) : ids;
992
- const data = await callApi("delete", `projects/test-plans/delete-test-plan/${projectId}`, { body: { ids: idList } });
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 } });
993
1477
  return jsonResponse(data);
994
1478
  }
995
1479
  case "list": {
@@ -1000,13 +1484,25 @@ async function handleManageTestPlan(args) {
1000
1484
  limit: p(args, "limit"),
1001
1485
  },
1002
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
+ }
1003
1495
  return jsonResponse(data);
1004
1496
  }
1005
1497
  case "get_details": {
1006
1498
  const planId = p(args, "planId", "plan_id", "id");
1007
1499
  if (!planId)
1008
1500
  throw new Error("planId is required for get_details action");
1501
+ validateId(planId, "planId");
1009
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
+ }
1010
1506
  return jsonResponse(data);
1011
1507
  }
1012
1508
  case "link_cycles": {
@@ -1016,8 +1512,9 @@ async function handleManageTestPlan(args) {
1016
1512
  throw new Error("planId is required for link_cycles action");
1017
1513
  if (!cycleIds)
1018
1514
  throw new Error("cycle_ids is required for link_cycles action");
1019
- const idList = typeof cycleIds === "string" ? cycleIds.split(",").map((s) => s.trim()) : cycleIds;
1020
- const data = await callApi("post", `projects/test-plans/testcycles/link/${projectId}/${planId}`, { body: { test_cycle_ids: idList } });
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 } });
1021
1518
  return jsonResponse(data);
1022
1519
  }
1023
1520
  case "unlink_cycles": {
@@ -1027,14 +1524,16 @@ async function handleManageTestPlan(args) {
1027
1524
  throw new Error("planId is required for unlink_cycles action");
1028
1525
  if (!cycleIds)
1029
1526
  throw new Error("cycle_ids is required for unlink_cycles action");
1030
- const idList = typeof cycleIds === "string" ? cycleIds.split(",").map((s) => s.trim()) : cycleIds;
1031
- const data = await callApi("delete", `projects/test-plans/testcycles/unlink/${projectId}/${planId}`, { body: { test_cycle_ids: idList } });
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 } });
1032
1530
  return jsonResponse(data);
1033
1531
  }
1034
1532
  case "get_cycles": {
1035
1533
  const planId = p(args, "planId", "plan_id", "id");
1036
1534
  if (!planId)
1037
1535
  throw new Error("planId is required for get_cycles action");
1536
+ validateId(planId, "planId");
1038
1537
  const data = await callApi("get", `projects/test-plans/testcycles/list/${projectId}/${planId}`, {
1039
1538
  query: {
1040
1539
  search: p(args, "search"),
@@ -1048,6 +1547,7 @@ async function handleManageTestPlan(args) {
1048
1547
  const planId = p(args, "planId", "plan_id", "id");
1049
1548
  if (!planId)
1050
1549
  throw new Error("planId is required for get_testcases action");
1550
+ validateId(planId, "planId");
1051
1551
  const data = await callApi("get", `projects/test-plans/testcases/list/${projectId}/${planId}`, {
1052
1552
  query: {
1053
1553
  search: p(args, "search"),
@@ -1067,6 +1567,7 @@ async function handleGetReport(args) {
1067
1567
  const reportType = p(args, "report_type", "reportType");
1068
1568
  if (!projectId)
1069
1569
  throw new Error("projectId is required");
1570
+ validateId(projectId, "projectId");
1070
1571
  if (!reportType)
1071
1572
  throw new Error("report_type is required");
1072
1573
  const endpointPath = REPORT_TYPE_MAP[reportType];
@@ -1074,20 +1575,31 @@ async function handleGetReport(args) {
1074
1575
  throw new Error(`Unknown report_type: ${reportType}. Valid types: ${Object.keys(REPORT_TYPE_MAP).join(", ")}`);
1075
1576
  }
1076
1577
  const query = {};
1077
- // Common filters
1078
- if (p(args, "test_cycle_ids", "testCycleIds"))
1079
- query.test_cycle_ids = p(args, "test_cycle_ids", "testCycleIds");
1080
- if (p(args, "test_plan_ids", "testPlanIds"))
1081
- query.test_plan_ids = p(args, "test_plan_ids", "testPlanIds");
1082
- if (p(args, "startDate", "start_date"))
1083
- query.startDate = p(args, "startDate", "start_date");
1084
- if (p(args, "endDate", "end_date"))
1085
- 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;
1086
1598
  if (p(args, "granularity"))
1087
1599
  query.granularity = p(args, "granularity");
1088
1600
  // cycle_comparison specific
1089
1601
  if (p(args, "cycle_ids", "cycleIds"))
1090
- query.cycle_ids = p(args, "cycle_ids", "cycleIds");
1602
+ query.cycleIds = p(args, "cycle_ids", "cycleIds");
1091
1603
  const data = await callApi("get", `projects/reports/${endpointPath}/${projectId}`, {
1092
1604
  query,
1093
1605
  });
@@ -1096,7 +1608,7 @@ async function handleGetReport(args) {
1096
1608
  // ═══════════════════════════════════════════════════════════════════════════════
1097
1609
  // SERVER SETUP
1098
1610
  // ═══════════════════════════════════════════════════════════════════════════════
1099
- const server = new Server({ name: "testkase-mcp-server", version: "2.1.0" }, { capabilities: { tools: {} } });
1611
+ const server = new Server({ name: "testkase-mcp-server", version: VERSION }, { capabilities: { tools: {} } });
1100
1612
  // List all 11 tools
1101
1613
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
1102
1614
  tools: [
@@ -1145,9 +1657,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1145
1657
  }
1146
1658
  }
1147
1659
  catch (error) {
1148
- const err = error instanceof Error ? error : handleApiError(error);
1660
+ const err = axios.isAxiosError(error) ? handleApiError(error) : (error instanceof Error ? error : new Error(String(error)));
1661
+ const enrichedMessage = enrichErrorMessage(err.message);
1149
1662
  return {
1150
- content: [{ type: "text", text: `Error: ${err.message}` }],
1663
+ content: [{ type: "text", text: `Error: ${enrichedMessage}` }],
1151
1664
  isError: true,
1152
1665
  };
1153
1666
  }
@@ -1156,7 +1669,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1156
1669
  async function main() {
1157
1670
  const transport = new StdioServerTransport();
1158
1671
  await server.connect(transport);
1159
- console.error("TestKase MCP Server v2.1.0 running on stdio (11 tools)");
1672
+ console.error(`TestKase MCP Server v${VERSION} running on stdio (11 tools) → ${config.apiBaseUrl}`);
1160
1673
  }
1161
1674
  main().catch((error) => {
1162
1675
  console.error("Fatal error in main():", error);