@testkase/mcp-server 2.1.0 → 2.2.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
@@ -7,6 +7,12 @@ const config = {
7
7
  apiBaseUrl: process.env.TESTKASE_API_BASE_URL || "https://apiqa.testkase.com",
8
8
  patToken: process.env.TESTKASE_PAT_TOKEN,
9
9
  };
10
+ // ─── Frontend URL helper ─────────────────────────────────────────────────────
11
+ function getFrontendBaseUrl() {
12
+ const url = new URL(config.apiBaseUrl);
13
+ url.hostname = url.hostname.replace(/^api\.?/, "");
14
+ return url.origin;
15
+ }
10
16
  // ─── Auth helpers (reused from v1) ───────────────────────────────────────────
11
17
  function validateAuth() {
12
18
  if (!config.patToken) {
@@ -76,7 +82,15 @@ function handleApiError(error) {
76
82
  let msg = `API request failed: ${e.message}`;
77
83
  if (e.response) {
78
84
  msg += `\nStatus: ${e.response.status}`;
79
- msg += `\nResponse: ${JSON.stringify(e.response.data)}`;
85
+ if (e.response.status === 401) {
86
+ cachedOrganizationId = null;
87
+ }
88
+ if (e.response.data) {
89
+ const d = e.response.data;
90
+ const safeMsg = d.message || d.error || (typeof d === "string" ? d : undefined);
91
+ if (safeMsg)
92
+ msg += `\nDetails: ${String(safeMsg).slice(0, 500)}`;
93
+ }
80
94
  }
81
95
  return new Error(msg);
82
96
  }
@@ -88,6 +102,88 @@ function jsonResponse(data) {
88
102
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
89
103
  };
90
104
  }
105
+ function safeJsonParse(raw, fieldName) {
106
+ if (typeof raw !== "string")
107
+ return raw;
108
+ try {
109
+ return JSON.parse(raw);
110
+ }
111
+ catch {
112
+ throw new Error(`Invalid JSON in ${fieldName}. Ensure the value is valid JSON.`);
113
+ }
114
+ }
115
+ function validateId(id, fieldName) {
116
+ if (!/^[a-zA-Z0-9_\-]+$/.test(id)) {
117
+ throw new Error(`Invalid ${fieldName}: contains disallowed characters`);
118
+ }
119
+ return id;
120
+ }
121
+ function jsonResponseWithHints(data, hints) {
122
+ const json = JSON.stringify(data, null, 2);
123
+ const hintsBlock = hints.length > 0
124
+ ? `\n\n---\nHINTS:\n${hints.map((h) => `- ${h}`).join("\n")}`
125
+ : "";
126
+ return {
127
+ content: [{ type: "text", text: json + hintsBlock }],
128
+ };
129
+ }
130
+ // ─── Error enrichment ───────────────────────────────────────────────────────
131
+ const ERROR_HINTS = [
132
+ { pattern: /Invalid value.*for field/i,
133
+ hint: "RECOVERY: Call get_project_structure(projectId, include='field_options') to discover allowed values for this field, then retry." },
134
+ { pattern: /must be a number/i,
135
+ hint: "RECOVERY: This field requires a numeric ID. Call get_project_structure(projectId) to find valid IDs." },
136
+ { pattern: /folder_id/i,
137
+ hint: "RECOVERY: folder_id must be a numeric folder ID. Call get_project_structure(projectId, include='folders') to list folders with their IDs." },
138
+ { pattern: /status.*(401|unauthorized)/i,
139
+ hint: "RECOVERY: PAT token may be expired. User needs to regenerate from TestKase account settings." },
140
+ { pattern: /priority/i,
141
+ hint: "RECOVERY: Priority values are project-specific. Call get_project_structure(projectId, include='field_options') to discover allowed values." },
142
+ ];
143
+ function enrichErrorMessage(errorMsg) {
144
+ for (const { pattern, hint } of ERROR_HINTS) {
145
+ if (pattern.test(errorMsg)) {
146
+ return `${errorMsg}\n\n${hint}`;
147
+ }
148
+ }
149
+ return errorMsg;
150
+ }
151
+ // ─── Field options cache (5-min TTL) ────────────────────────────────────────
152
+ const fieldOptionsCache = new Map();
153
+ const FIELD_OPTIONS_TTL = 5 * 60 * 1000; // 5 minutes
154
+ async function getFieldOptions(projectId) {
155
+ const cached = fieldOptionsCache.get(projectId);
156
+ if (cached && Date.now() - cached.ts < FIELD_OPTIONS_TTL) {
157
+ return cached.data;
158
+ }
159
+ try {
160
+ const response = await callApi("get", `projects/fields/options/${projectId}`);
161
+ const options = response?.data || response || [];
162
+ fieldOptionsCache.set(projectId, { data: options, ts: Date.now() });
163
+ return options;
164
+ }
165
+ catch {
166
+ return cached?.data || [];
167
+ }
168
+ }
169
+ async function coerceFieldValue(projectId, fieldName, rawValue) {
170
+ if (rawValue === undefined || rawValue === null)
171
+ return rawValue;
172
+ const options = await getFieldOptions(projectId);
173
+ const fieldDef = options.find((f) => f.fieldName?.toLowerCase() === fieldName.toLowerCase());
174
+ if (!fieldDef?.values || !Array.isArray(fieldDef.values))
175
+ return rawValue;
176
+ const strValue = String(rawValue);
177
+ // Exact match
178
+ if (fieldDef.values.includes(strValue))
179
+ return strValue;
180
+ // Case-insensitive match
181
+ const match = fieldDef.values.find((v) => v.toLowerCase() === strValue.toLowerCase());
182
+ if (match)
183
+ return match;
184
+ // No match — return as-is, let API validate
185
+ return rawValue;
186
+ }
91
187
  // ─── Param normalizer (handles camelCase / snake_case) ───────────────────────
92
188
  function p(args, ...names) {
93
189
  for (const n of names) {
@@ -115,8 +211,8 @@ const LIST_PROJECTS_TOOL = {
115
211
  };
116
212
  const GET_PROJECT_STRUCTURE_TOOL = {
117
213
  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.",
214
+ description: "Get the structure of a project: folders, labels, team members, and field options (allowed values for priority, status, etc.). " +
215
+ "IMPORTANT: Call this before create/update to discover allowed field values, which are project-specific.",
120
216
  inputSchema: {
121
217
  type: "object",
122
218
  properties: {
@@ -128,7 +224,7 @@ const GET_PROJECT_STRUCTURE_TOOL = {
128
224
  },
129
225
  include: {
130
226
  type: "string",
131
- description: "Comma-separated list of what to include: folders,labels,members (default: all)",
227
+ description: "Comma-separated list of what to include: folders,labels,members,field_options (default: all)",
132
228
  },
133
229
  },
134
230
  required: ["projectId"],
@@ -168,8 +264,18 @@ const GET_TESTCASE_TOOL = {
168
264
  };
169
265
  const MANAGE_TESTCASE_TOOL = {
170
266
  name: "manage_testcase",
171
- description: "Create, bulk create, update, or delete test cases. " +
172
- "Set 'action' to: create, create_bulk, update, or delete.",
267
+ 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" +
269
+ "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" +
275
+ "EXAMPLES:\n" +
276
+ " Create: { action: 'create', title: 'Verify login' }\n" +
277
+ " Update: { action: 'update', ids: 'TEST-1', field: 'priority', value: '<value from get_project_structure>' }\n" +
278
+ " Delete: { action: 'delete', ids: 'TEST-1,TEST-2' }",
173
279
  inputSchema: {
174
280
  type: "object",
175
281
  properties: {
@@ -184,7 +290,7 @@ const MANAGE_TESTCASE_TOOL = {
184
290
  summary: { type: "string", description: "[create] Description/summary" },
185
291
  priority: {
186
292
  type: "string",
187
- description: "[create] Priority: low, medium, high, or critical",
293
+ description: "[create] Priority. Values are project-specific — call get_project_structure(projectId, include='field_options') first to discover allowed values.",
188
294
  },
189
295
  folder_id: { type: "string", description: "[create] Folder ID to place the test case in" },
190
296
  labels: {
@@ -193,30 +299,57 @@ const MANAGE_TESTCASE_TOOL = {
193
299
  },
194
300
  test_steps: {
195
301
  type: "string",
196
- description: '[create] JSON array of test steps: [{"step":"...","expected_result":"..."}]',
302
+ description: '[create] JSON array of test steps: [{"description":"...","test_data":"...","expected_result":"..."}]',
303
+ },
304
+ precondition: { type: "string", description: "[create] Preconditions for the test case" },
305
+ status: {
306
+ type: "string",
307
+ description: "[create] Status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
308
+ },
309
+ automated: {
310
+ type: "string",
311
+ description: "[create] Automation status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
312
+ },
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" },
315
+ platform: {
316
+ type: "string",
317
+ description: "[create] Platform. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
318
+ },
319
+ environment: { type: "string", description: "[create] Comma-separated environment values" },
320
+ features: { type: "string", description: "[create] Comma-separated feature/component names" },
321
+ owner_id: { type: "string", description: "[create] Owner user ID" },
322
+ custom_fields: {
323
+ type: "string",
324
+ description: '[create] JSON object of custom field values (e.g. {"custom1": "value", "custom2": ["val1","val2"]})',
197
325
  },
198
326
  // create_bulk params
199
327
  testcases: {
200
328
  type: "string",
201
329
  description: "[create_bulk] JSON array of test cases, each with title, summary, priority, folder_id, test_steps",
202
330
  },
203
- // update params
331
+ // update/delete params
204
332
  ids: {
205
333
  type: "string",
206
- description: "[update/delete] Comma-separated test case IDs",
334
+ description: "[update/delete] Test case IDs, comma-separated (e.g. 'TEST-1' or 'TEST-1,TEST-2')",
207
335
  },
208
336
  field: {
209
337
  type: "string",
210
- description: "[update] Field name to update (title, summary, priority, status, etc.)",
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.",
211
339
  },
212
- value: { type: "string", description: "[update] New value for the field" },
340
+ 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\"})" },
213
341
  },
214
342
  required: ["projectId", "action"],
215
343
  },
216
344
  };
217
345
  const MANAGE_FOLDER_TOOL = {
218
346
  name: "manage_folder",
219
- description: "Create, rename, move, or delete folders. Works across all sections (TESTCASE, TEST_CYCLE, TEST_PLAN).",
347
+ description: "Create, rename, move, or delete folders. Works across all sections (TESTCASE, TEST_CYCLE, TEST_PLAN).\n\n" +
348
+ "ACTIONS:\n" +
349
+ " create - Create a folder. Params: name (required), section, parentId\n" +
350
+ " 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)",
220
353
  inputSchema: {
221
354
  type: "object",
222
355
  properties: {
@@ -262,8 +395,17 @@ const SEARCH_TEST_CYCLES_TOOL = {
262
395
  };
263
396
  const MANAGE_TEST_CYCLE_TOOL = {
264
397
  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.",
398
+ description: "Manage test cycles: create, update, delete, get details, list/link/unlink/assign test cases.\n\n" +
399
+ "ACTIONS:\n" +
400
+ " create - Create a cycle. Params: title (required), summary, status, planned_start_date, planned_end_date, folder_id\n" +
401
+ " update - Update a cycle. Params: cycleId (required), plus fields to change (title, summary, status, dates, folder_id)\n" +
402
+ " delete - Delete cycle(s). Params: ids (required, comma-separated)\n" +
403
+ " get_details - Get cycle details. Params: cycleId (required)\n" +
404
+ " get_testcases - List test cases in cycle. Params: cycleId (required), search, page, limit\n" +
405
+ " link_testcases - Link test cases to cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
406
+ " unlink_testcases - Unlink test cases from cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
407
+ " assign_testcases - Assign test cases to user. Params: cycleId (required), testcase_ids (required), assignee_id (required)\n\n" +
408
+ "VALID STATUSES: open, in_progress, completed",
267
409
  inputSchema: {
268
410
  type: "object",
269
411
  properties: {
@@ -292,7 +434,7 @@ const MANAGE_TEST_CYCLE_TOOL = {
292
434
  summary: { type: "string", description: "[create/update] Description" },
293
435
  status: {
294
436
  type: "string",
295
- description: "[create/update] Status: open, closed, in progress",
437
+ description: "[create/update] Status: open, in_progress, completed",
296
438
  },
297
439
  planned_start_date: {
298
440
  type: "string",
@@ -332,7 +474,7 @@ const EXECUTE_TESTS_TOOL = {
332
474
  name: "execute_tests",
333
475
  description: "Record test execution results for test cases in a test cycle. " +
334
476
  "Pass a single result or an array of results. " +
335
- "Valid execution statuses: pass, fail, blocked, not_executed, in_progress.",
477
+ "Valid execution statuses: pass, fail, blocked, not_executed.",
336
478
  inputSchema: {
337
479
  type: "object",
338
480
  properties: {
@@ -349,8 +491,17 @@ const EXECUTE_TESTS_TOOL = {
349
491
  };
350
492
  const MANAGE_TEST_PLAN_TOOL = {
351
493
  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.",
494
+ description: "Manage test plans: create, update, delete, list, get details, link/unlink cycles, get cycles, get test cases.\n\n" +
495
+ "ACTIONS:\n" +
496
+ " create - Create a plan. Params: title (required), summary, status, planned_start_date, planned_end_date, folder_id\n" +
497
+ " update - Update a plan. Params: planId (required), plus fields to change (title, summary, status, dates, folder_id)\n" +
498
+ " delete - Delete plan(s). Params: ids (required, comma-separated)\n" +
499
+ " list - List all plans. Params: search, page, limit\n" +
500
+ " get_details - Get plan details. Params: planId (required)\n" +
501
+ " link_cycles - Link cycles to plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
502
+ " unlink_cycles - Unlink cycles from plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
503
+ " 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",
354
505
  inputSchema: {
355
506
  type: "object",
356
507
  properties: {
@@ -517,15 +668,18 @@ async function handleListProjects(args) {
517
668
  limit: p(args, "limit"),
518
669
  },
519
670
  });
520
- return jsonResponse(data);
671
+ return jsonResponseWithHints(data, [
672
+ "Next: call get_project_structure(projectId) to see folders, labels, and allowed field values.",
673
+ ]);
521
674
  }
522
675
  // 2. get_project_structure
523
676
  async function handleGetProjectStructure(args) {
524
677
  const projectId = p(args, "projectId", "project_id");
525
678
  if (!projectId)
526
679
  throw new Error("projectId is required");
680
+ validateId(projectId, "projectId");
527
681
  const section = p(args, "section") || "TESTCASE";
528
- const includeRaw = p(args, "include") || "folders,labels,members";
682
+ const includeRaw = p(args, "include") || "folders,labels,members,field_options";
529
683
  const includes = includeRaw.split(",").map((s) => s.trim().toLowerCase());
530
684
  const orgId = await getOrganizationId();
531
685
  const result = {};
@@ -547,14 +701,22 @@ async function handleGetProjectStructure(args) {
547
701
  result.members = data;
548
702
  }));
549
703
  }
704
+ if (includes.includes("field_options")) {
705
+ promises.push(callApi("get", `projects/fields/options/${projectId}`).then((data) => {
706
+ result.field_options = data?.data || data;
707
+ }));
708
+ }
550
709
  await Promise.all(promises);
551
- return jsonResponse(result);
710
+ return jsonResponseWithHints(result, [
711
+ "Use the field_options values above when creating/updating test cases.",
712
+ ]);
552
713
  }
553
714
  // 3. search_testcases
554
715
  async function handleSearchTestcases(args) {
555
716
  const projectId = p(args, "projectId", "project_id");
556
717
  if (!projectId)
557
718
  throw new Error("projectId is required");
719
+ validateId(projectId, "projectId");
558
720
  const data = await callApi("get", `projects/testcases/get-testcase/${projectId}`, {
559
721
  query: {
560
722
  search: p(args, "search"),
@@ -565,6 +727,13 @@ async function handleSearchTestcases(args) {
565
727
  sortOrder: p(args, "sortOrder", "sort_order"),
566
728
  },
567
729
  });
730
+ const baseUrl = getFrontendBaseUrl();
731
+ if (Array.isArray(data?.data)) {
732
+ data.data.forEach((tc) => {
733
+ if (tc.id)
734
+ tc.url = `${baseUrl}/${projectId}/testcases/${tc.id}`;
735
+ });
736
+ }
568
737
  return jsonResponse(data);
569
738
  }
570
739
  // 4. get_testcase
@@ -575,7 +744,12 @@ async function handleGetTestcase(args) {
575
744
  throw new Error("projectId is required");
576
745
  if (!testcaseId)
577
746
  throw new Error("testcaseId is required");
747
+ validateId(projectId, "projectId");
748
+ validateId(testcaseId, "testcaseId");
578
749
  const data = await callApi("get", `projects/testcases/get-testcase-detail/${projectId}/${testcaseId}`);
750
+ if (data?.data?.testcase) {
751
+ data.data.testcase.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${testcaseId}`;
752
+ }
579
753
  return jsonResponse(data);
580
754
  }
581
755
  // 5. manage_testcase
@@ -586,6 +760,7 @@ async function handleManageTestcase(args) {
586
760
  throw new Error("projectId is required");
587
761
  if (!action)
588
762
  throw new Error("action is required");
763
+ validateId(projectId, "projectId");
589
764
  const orgId = await getOrganizationId();
590
765
  switch (action) {
591
766
  case "create": {
@@ -596,40 +771,94 @@ async function handleManageTestcase(args) {
596
771
  if (p(args, "summary", "description"))
597
772
  testcaseData.summary = p(args, "summary", "description");
598
773
  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");
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
+ }
602
780
  const labelsRaw = p(args, "labels");
603
781
  if (labelsRaw) {
604
782
  testcaseData.labels =
605
783
  typeof labelsRaw === "string"
606
- ? labelsRaw.split(",").map((l) => l.trim())
784
+ ? labelsRaw.split(",").map((l) => l.trim()).filter(Boolean)
607
785
  : labelsRaw;
608
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
+ }
609
826
  // Parse test_steps
610
827
  const stepsRaw = p(args, "test_steps", "testSteps", "steps");
611
- const testSteps = stepsRaw
612
- ? typeof stepsRaw === "string"
613
- ? JSON.parse(stepsRaw)
614
- : stepsRaw
615
- : null;
828
+ const testSteps = stepsRaw ? safeJsonParse(stepsRaw, "test_steps") : null;
616
829
  // Step 1: Create testcase
617
830
  const createResult = await callApi("post", `projects/testcases/create-testcase/${projectId}`, { body: testcaseData });
618
831
  // Step 2: Add test steps if provided
619
832
  const created = createResult?.data || createResult;
620
833
  const tcId = created?.id || created?.testcaseId;
621
834
  if (testSteps && Array.isArray(testSteps) && testSteps.length > 0 && tcId) {
622
- await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, {
623
- body: { steps: testSteps },
624
- });
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
+ }
625
846
  }
626
- return jsonResponse(createResult);
847
+ if (tcId) {
848
+ const created2 = createResult?.data || createResult;
849
+ if (created2 && typeof created2 === "object") {
850
+ created2.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${tcId}`;
851
+ }
852
+ }
853
+ return jsonResponseWithHints(createResult, [
854
+ "To update fields, use manage_testcase(action='update', ids='<id>', field='<name>', value='<value>')",
855
+ ]);
627
856
  }
628
857
  case "create_bulk": {
629
858
  const rawTestcases = p(args, "testcases", "test_cases", "items");
630
859
  if (!rawTestcases)
631
860
  throw new Error("testcases is required for create_bulk action");
632
- const testcases = typeof rawTestcases === "string" ? JSON.parse(rawTestcases) : rawTestcases;
861
+ const testcases = safeJsonParse(rawTestcases, "testcases");
633
862
  // Separate test_steps for post-creation
634
863
  const withSteps = testcases.map((tc) => {
635
864
  const normalized = { title: tc.title || tc.name };
@@ -637,8 +866,22 @@ async function handleManageTestcase(args) {
637
866
  normalized.summary = tc.summary || tc.description;
638
867
  if (tc.priority)
639
868
  normalized.priority = tc.priority;
640
- if (tc.folder_id || tc.folderId)
641
- normalized.folder_id = tc.folder_id || tc.folderId;
869
+ const rawFolderId = tc.folder_id || tc.folderId;
870
+ if (rawFolderId) {
871
+ const numFolderId = Number(rawFolderId);
872
+ normalized.folder_id = isNaN(numFolderId) ? rawFolderId : numFolderId;
873
+ }
874
+ if (tc.labels) {
875
+ normalized.labels = typeof tc.labels === "string"
876
+ ? tc.labels.split(",").map((l) => l.trim()).filter(Boolean)
877
+ : tc.labels;
878
+ }
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;
642
885
  return {
643
886
  testcase: normalized,
644
887
  test_steps: tc.test_steps || tc.testSteps || tc.steps,
@@ -656,9 +899,17 @@ async function handleManageTestcase(args) {
656
899
  const tcId = r.testcaseId || r.id;
657
900
  if (tcId) {
658
901
  try {
659
- await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, {
660
- body: { steps },
661
- });
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
+ }
662
913
  stepsCreated++;
663
914
  }
664
915
  catch {
@@ -679,9 +930,33 @@ async function handleManageTestcase(args) {
679
930
  throw new Error("field is required for update action");
680
931
  if (value === undefined)
681
932
  throw new Error("value is required for update action");
682
- const idList = typeof ids === "string" ? ids.split(",").map((s) => s.trim()) : ids;
933
+ const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
934
+ // Coerce field values for project-specific fields
935
+ const coercibleFields = ["priority", "status", "automated", "platform"];
936
+ // Parse value for fields that expect non-string types
937
+ let parsedValue = value;
938
+ if (field === "folder_id" || field === "owner_id") {
939
+ const num = Number(value);
940
+ parsedValue = isNaN(num) ? value : num;
941
+ }
942
+ else if (coercibleFields.includes(field)) {
943
+ parsedValue = await coerceFieldValue(projectId, field, value);
944
+ }
945
+ else if (field === "labels" || field === "features" || field === "environment") {
946
+ parsedValue = typeof value === "string" ? value.split(",").map((s) => s.trim()).filter(Boolean) : value;
947
+ }
948
+ else if (field === "custom_fields") {
949
+ const parsed = safeJsonParse(value, "custom_fields");
950
+ // Backend requires all custom field values as arrays
951
+ for (const key of Object.keys(parsed)) {
952
+ if (!Array.isArray(parsed[key])) {
953
+ parsed[key] = [parsed[key]];
954
+ }
955
+ }
956
+ parsedValue = parsed;
957
+ }
683
958
  const data = await callApi("patch", `projects/testcases/update-field/${projectId}`, {
684
- body: { ids: idList, field, value },
959
+ body: { ids: idsStr, [field]: parsedValue },
685
960
  });
686
961
  return jsonResponse(data);
687
962
  }
@@ -689,9 +964,9 @@ async function handleManageTestcase(args) {
689
964
  const ids = p(args, "ids", "testcase_ids", "testcaseIds");
690
965
  if (!ids)
691
966
  throw new Error("ids is required for delete action");
692
- const idList = typeof ids === "string" ? ids.split(",").map((s) => s.trim()) : ids;
967
+ const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
693
968
  const data = await callApi("delete", `projects/testcases/delete-testcase/${projectId}`, {
694
- body: { ids: idList },
969
+ query: { ids: idsStr },
695
970
  });
696
971
  return jsonResponse(data);
697
972
  }
@@ -707,6 +982,7 @@ async function handleManageFolder(args) {
707
982
  throw new Error("projectId is required");
708
983
  if (!action)
709
984
  throw new Error("action is required");
985
+ validateId(projectId, "projectId");
710
986
  const section = p(args, "section") || "TESTCASE";
711
987
  const orgId = await getOrganizationId();
712
988
  switch (action) {
@@ -733,6 +1009,7 @@ async function handleManageFolder(args) {
733
1009
  throw new Error("folderId is required for rename action");
734
1010
  if (!name)
735
1011
  throw new Error("name is required for rename action");
1012
+ validateId(folderId, "folderId");
736
1013
  const data = await callApi("patch", `folders/${folderId}`, {
737
1014
  query: { projectId, section },
738
1015
  body: { name },
@@ -743,6 +1020,7 @@ async function handleManageFolder(args) {
743
1020
  const folderId = p(args, "folderId", "folder_id", "id");
744
1021
  if (!folderId)
745
1022
  throw new Error("folderId is required for move action");
1023
+ validateId(folderId, "folderId");
746
1024
  const parentId = p(args, "parentId", "parent_id");
747
1025
  const data = await callApi("patch", `folders/${folderId}/move`, {
748
1026
  query: { projectId, section },
@@ -754,6 +1032,7 @@ async function handleManageFolder(args) {
754
1032
  const folderId = p(args, "folderId", "folder_id", "id");
755
1033
  if (!folderId)
756
1034
  throw new Error("folderId is required for delete action");
1035
+ validateId(folderId, "folderId");
757
1036
  const data = await callApi("delete", `folders/delete/${folderId}`, {
758
1037
  query: { projectId, section },
759
1038
  });
@@ -768,6 +1047,7 @@ async function handleSearchTestCycles(args) {
768
1047
  const projectId = p(args, "projectId", "project_id");
769
1048
  if (!projectId)
770
1049
  throw new Error("projectId is required");
1050
+ validateId(projectId, "projectId");
771
1051
  const data = await callApi("get", `projects/test-cycles/get-test-cycles/${projectId}`, {
772
1052
  query: {
773
1053
  search: p(args, "search"),
@@ -777,6 +1057,13 @@ async function handleSearchTestCycles(args) {
777
1057
  limit: p(args, "limit"),
778
1058
  },
779
1059
  });
1060
+ const baseUrl = getFrontendBaseUrl();
1061
+ if (Array.isArray(data?.data)) {
1062
+ data.data.forEach((cycle) => {
1063
+ if (cycle.id)
1064
+ cycle.url = `${baseUrl}/${projectId}/testcycles/${cycle.id}`;
1065
+ });
1066
+ }
780
1067
  return jsonResponse(data);
781
1068
  }
782
1069
  // 8. manage_test_cycle
@@ -787,6 +1074,7 @@ async function handleManageTestCycle(args) {
787
1074
  throw new Error("projectId is required");
788
1075
  if (!action)
789
1076
  throw new Error("action is required");
1077
+ validateId(projectId, "projectId");
790
1078
  switch (action) {
791
1079
  case "create": {
792
1080
  const title = p(args, "title");
@@ -830,21 +1118,26 @@ async function handleManageTestCycle(args) {
830
1118
  const ids = p(args, "ids", "cycleId", "cycle_id");
831
1119
  if (!ids)
832
1120
  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 } });
1121
+ const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
1122
+ const data = await callApi("delete", `projects/test-cycles/delete-test-cycle/${projectId}`, { body: { ids: idsStr } });
835
1123
  return jsonResponse(data);
836
1124
  }
837
1125
  case "get_details": {
838
1126
  const cycleId = p(args, "cycleId", "cycle_id", "id");
839
1127
  if (!cycleId)
840
1128
  throw new Error("cycleId is required for get_details action");
1129
+ validateId(cycleId, "cycleId");
841
1130
  const data = await callApi("get", `projects/test-cycles/get-test-cycle-details/${projectId}/${cycleId}`);
1131
+ if (data?.data) {
1132
+ data.data.url = `${getFrontendBaseUrl()}/${projectId}/testcycles/${cycleId}`;
1133
+ }
842
1134
  return jsonResponse(data);
843
1135
  }
844
1136
  case "get_testcases": {
845
1137
  const cycleId = p(args, "cycleId", "cycle_id", "id");
846
1138
  if (!cycleId)
847
1139
  throw new Error("cycleId is required for get_testcases action");
1140
+ validateId(cycleId, "cycleId");
848
1141
  const data = await callApi("get", `projects/test-cycles/testcases/${projectId}/${cycleId}`, {
849
1142
  query: {
850
1143
  search: p(args, "search"),
@@ -861,8 +1154,9 @@ async function handleManageTestCycle(args) {
861
1154
  throw new Error("cycleId is required for link_testcases action");
862
1155
  if (!tcIds)
863
1156
  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 } });
1157
+ validateId(cycleId, "cycleId");
1158
+ const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
1159
+ const data = await callApi("post", `projects/test-cycles/testcases/link/${projectId}/${cycleId}`, { body: { ids: idsStr } });
866
1160
  return jsonResponse(data);
867
1161
  }
868
1162
  case "unlink_testcases": {
@@ -872,8 +1166,9 @@ async function handleManageTestCycle(args) {
872
1166
  throw new Error("cycleId is required for unlink_testcases action");
873
1167
  if (!tcIds)
874
1168
  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 } });
1169
+ validateId(cycleId, "cycleId");
1170
+ const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
1171
+ const data = await callApi("delete", `projects/test-cycles/testcases/unlink/${projectId}/${cycleId}`, { body: { ids: idsStr } });
877
1172
  return jsonResponse(data);
878
1173
  }
879
1174
  case "assign_testcases": {
@@ -886,8 +1181,9 @@ async function handleManageTestCycle(args) {
886
1181
  throw new Error("testcase_ids is required for assign_testcases action");
887
1182
  if (!assigneeId)
888
1183
  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 } });
1184
+ validateId(cycleId, "cycleId");
1185
+ const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
1186
+ const data = await callApi("patch", `projects/test-cycles/testcases/assign/${projectId}/${cycleId}`, { body: { ids: idsStr, assignee: Number(assigneeId) } });
891
1187
  return jsonResponse(data);
892
1188
  }
893
1189
  default:
@@ -905,16 +1201,25 @@ async function handleExecuteTests(args) {
905
1201
  throw new Error("cycleId is required");
906
1202
  if (!resultsRaw)
907
1203
  throw new Error("results is required");
908
- const results = typeof resultsRaw === "string" ? JSON.parse(resultsRaw) : resultsRaw;
1204
+ validateId(projectId, "projectId");
1205
+ validateId(cycleId, "cycleId");
1206
+ const results = safeJsonParse(resultsRaw, "results");
909
1207
  if (!Array.isArray(results) || results.length === 0) {
910
1208
  throw new Error("results must be a non-empty array");
911
1209
  }
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
+ }
1218
+ }
912
1219
  // Single result → use PATCH update-execution endpoint
913
1220
  if (results.length === 1) {
914
1221
  const r = results[0];
915
1222
  const testcaseId = r.testcase_id || r.testcaseId;
916
- if (!testcaseId)
917
- throw new Error("testcase_id is required in each result");
918
1223
  const body = {
919
1224
  execution_status: r.execution_status || r.executionStatus,
920
1225
  };
@@ -945,6 +1250,7 @@ async function handleManageTestPlan(args) {
945
1250
  throw new Error("projectId is required");
946
1251
  if (!action)
947
1252
  throw new Error("action is required");
1253
+ validateId(projectId, "projectId");
948
1254
  switch (action) {
949
1255
  case "create": {
950
1256
  const title = p(args, "title");
@@ -988,8 +1294,8 @@ async function handleManageTestPlan(args) {
988
1294
  const ids = p(args, "ids", "planId", "plan_id");
989
1295
  if (!ids)
990
1296
  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 } });
1297
+ const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
1298
+ const data = await callApi("delete", `projects/test-plans/delete-test-plan/${projectId}`, { body: { ids: idsStr } });
993
1299
  return jsonResponse(data);
994
1300
  }
995
1301
  case "list": {
@@ -1000,13 +1306,24 @@ async function handleManageTestPlan(args) {
1000
1306
  limit: p(args, "limit"),
1001
1307
  },
1002
1308
  });
1309
+ const baseUrl = getFrontendBaseUrl();
1310
+ if (Array.isArray(data?.data)) {
1311
+ data.data.forEach((plan) => {
1312
+ if (plan.id)
1313
+ plan.url = `${baseUrl}/${projectId}/testplans/${plan.id}`;
1314
+ });
1315
+ }
1003
1316
  return jsonResponse(data);
1004
1317
  }
1005
1318
  case "get_details": {
1006
1319
  const planId = p(args, "planId", "plan_id", "id");
1007
1320
  if (!planId)
1008
1321
  throw new Error("planId is required for get_details action");
1322
+ validateId(planId, "planId");
1009
1323
  const data = await callApi("get", `projects/test-plans/get-test-plan-details/${projectId}/${planId}`);
1324
+ if (data?.data) {
1325
+ data.data.url = `${getFrontendBaseUrl()}/${projectId}/testplans/${planId}`;
1326
+ }
1010
1327
  return jsonResponse(data);
1011
1328
  }
1012
1329
  case "link_cycles": {
@@ -1016,8 +1333,9 @@ async function handleManageTestPlan(args) {
1016
1333
  throw new Error("planId is required for link_cycles action");
1017
1334
  if (!cycleIds)
1018
1335
  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 } });
1336
+ validateId(planId, "planId");
1337
+ const idsStr = Array.isArray(cycleIds) ? cycleIds.join(",") : String(cycleIds);
1338
+ const data = await callApi("post", `projects/test-plans/testcycles/link/${projectId}/${planId}`, { body: { ids: idsStr } });
1021
1339
  return jsonResponse(data);
1022
1340
  }
1023
1341
  case "unlink_cycles": {
@@ -1027,14 +1345,16 @@ async function handleManageTestPlan(args) {
1027
1345
  throw new Error("planId is required for unlink_cycles action");
1028
1346
  if (!cycleIds)
1029
1347
  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 } });
1348
+ validateId(planId, "planId");
1349
+ const idsStr = Array.isArray(cycleIds) ? cycleIds.join(",") : String(cycleIds);
1350
+ const data = await callApi("delete", `projects/test-plans/testcycles/unlink/${projectId}/${planId}`, { body: { ids: idsStr } });
1032
1351
  return jsonResponse(data);
1033
1352
  }
1034
1353
  case "get_cycles": {
1035
1354
  const planId = p(args, "planId", "plan_id", "id");
1036
1355
  if (!planId)
1037
1356
  throw new Error("planId is required for get_cycles action");
1357
+ validateId(planId, "planId");
1038
1358
  const data = await callApi("get", `projects/test-plans/testcycles/list/${projectId}/${planId}`, {
1039
1359
  query: {
1040
1360
  search: p(args, "search"),
@@ -1048,6 +1368,7 @@ async function handleManageTestPlan(args) {
1048
1368
  const planId = p(args, "planId", "plan_id", "id");
1049
1369
  if (!planId)
1050
1370
  throw new Error("planId is required for get_testcases action");
1371
+ validateId(planId, "planId");
1051
1372
  const data = await callApi("get", `projects/test-plans/testcases/list/${projectId}/${planId}`, {
1052
1373
  query: {
1053
1374
  search: p(args, "search"),
@@ -1067,6 +1388,7 @@ async function handleGetReport(args) {
1067
1388
  const reportType = p(args, "report_type", "reportType");
1068
1389
  if (!projectId)
1069
1390
  throw new Error("projectId is required");
1391
+ validateId(projectId, "projectId");
1070
1392
  if (!reportType)
1071
1393
  throw new Error("report_type is required");
1072
1394
  const endpointPath = REPORT_TYPE_MAP[reportType];
@@ -1075,10 +1397,10 @@ async function handleGetReport(args) {
1075
1397
  }
1076
1398
  const query = {};
1077
1399
  // 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");
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");
1082
1404
  if (p(args, "startDate", "start_date"))
1083
1405
  query.startDate = p(args, "startDate", "start_date");
1084
1406
  if (p(args, "endDate", "end_date"))
@@ -1087,7 +1409,7 @@ async function handleGetReport(args) {
1087
1409
  query.granularity = p(args, "granularity");
1088
1410
  // cycle_comparison specific
1089
1411
  if (p(args, "cycle_ids", "cycleIds"))
1090
- query.cycle_ids = p(args, "cycle_ids", "cycleIds");
1412
+ query.cycleIds = p(args, "cycle_ids", "cycleIds");
1091
1413
  const data = await callApi("get", `projects/reports/${endpointPath}/${projectId}`, {
1092
1414
  query,
1093
1415
  });
@@ -1096,7 +1418,7 @@ async function handleGetReport(args) {
1096
1418
  // ═══════════════════════════════════════════════════════════════════════════════
1097
1419
  // SERVER SETUP
1098
1420
  // ═══════════════════════════════════════════════════════════════════════════════
1099
- const server = new Server({ name: "testkase-mcp-server", version: "2.1.0" }, { capabilities: { tools: {} } });
1421
+ const server = new Server({ name: "testkase-mcp-server", version: "2.2.0" }, { capabilities: { tools: {} } });
1100
1422
  // List all 11 tools
1101
1423
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
1102
1424
  tools: [
@@ -1145,9 +1467,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1145
1467
  }
1146
1468
  }
1147
1469
  catch (error) {
1148
- const err = error instanceof Error ? error : handleApiError(error);
1470
+ const err = axios.isAxiosError(error) ? handleApiError(error) : (error instanceof Error ? error : new Error(String(error)));
1471
+ const enrichedMessage = enrichErrorMessage(err.message);
1149
1472
  return {
1150
- content: [{ type: "text", text: `Error: ${err.message}` }],
1473
+ content: [{ type: "text", text: `Error: ${enrichedMessage}` }],
1151
1474
  isError: true,
1152
1475
  };
1153
1476
  }
@@ -1156,7 +1479,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1156
1479
  async function main() {
1157
1480
  const transport = new StdioServerTransport();
1158
1481
  await server.connect(transport);
1159
- console.error("TestKase MCP Server v2.1.0 running on stdio (11 tools)");
1482
+ console.error("TestKase MCP Server v2.2.0 running on stdio (11 tools)");
1160
1483
  }
1161
1484
  main().catch((error) => {
1162
1485
  console.error("Fatal error in main():", error);