@testkase/mcp-server 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -187
- package/build/index.js +426 -236
- package/build/index.js.map +1 -1
- package/package.json +40 -40
package/build/index.js
CHANGED
|
@@ -4,12 +4,28 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import axios from "axios";
|
|
6
6
|
const config = {
|
|
7
|
-
apiBaseUrl: process.env.TESTKASE_API_BASE_URL || "https://
|
|
7
|
+
apiBaseUrl: (process.env.TESTKASE_API_BASE_URL || "https://api.testkase.com").replace(/\/+$/, ""),
|
|
8
8
|
patToken: process.env.TESTKASE_PAT_TOKEN,
|
|
9
|
+
// Only needed when the PAT user belongs to more than one organization
|
|
10
|
+
organizationId: process.env.TESTKASE_ORGANIZATION_ID
|
|
11
|
+
? Number(process.env.TESTKASE_ORGANIZATION_ID)
|
|
12
|
+
: undefined,
|
|
13
|
+
appUrl: process.env.TESTKASE_APP_URL?.replace(/\/+$/, ""),
|
|
9
14
|
};
|
|
15
|
+
const VERSION = "2.3.0";
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
10
17
|
// ─── Frontend URL helper ─────────────────────────────────────────────────────
|
|
18
|
+
// Test management lives on its own subdomain
|
|
19
|
+
const KNOWN_APP_HOSTS = {
|
|
20
|
+
"api.testkase.com": "https://test-management.testkase.com",
|
|
21
|
+
"apiqa.testkase.com": "https://test-management-qa.testkase.com",
|
|
22
|
+
};
|
|
11
23
|
function getFrontendBaseUrl() {
|
|
24
|
+
if (config.appUrl)
|
|
25
|
+
return config.appUrl;
|
|
12
26
|
const url = new URL(config.apiBaseUrl);
|
|
27
|
+
if (KNOWN_APP_HOSTS[url.hostname])
|
|
28
|
+
return KNOWN_APP_HOSTS[url.hostname];
|
|
13
29
|
url.hostname = url.hostname.replace(/^api\.?/, "");
|
|
14
30
|
return url.origin;
|
|
15
31
|
}
|
|
@@ -33,11 +49,13 @@ function getApiHeaders() {
|
|
|
33
49
|
// ─── Cached org ID (auto-resolved from PAT, never a tool param) ─────────────
|
|
34
50
|
let cachedOrganizationId = null;
|
|
35
51
|
async function getOrganizationId() {
|
|
52
|
+
if (config.organizationId && !isNaN(config.organizationId))
|
|
53
|
+
return config.organizationId;
|
|
36
54
|
if (cachedOrganizationId !== null)
|
|
37
55
|
return cachedOrganizationId;
|
|
38
56
|
validateAuth();
|
|
39
57
|
try {
|
|
40
|
-
const response = await axios.get(`${config.apiBaseUrl}/api/v1/organizations/my-organization`, { headers: getApiHeaders() });
|
|
58
|
+
const response = await axios.get(`${config.apiBaseUrl}/api/v1/organizations/my-organization`, { headers: getApiHeaders(), timeout: REQUEST_TIMEOUT_MS });
|
|
41
59
|
const orgData = response.data?.data || response.data;
|
|
42
60
|
if (orgData?.organization_id) {
|
|
43
61
|
cachedOrganizationId = orgData.organization_id;
|
|
@@ -48,7 +66,10 @@ async function getOrganizationId() {
|
|
|
48
66
|
catch (error) {
|
|
49
67
|
if (error instanceof Error && error.message.includes("Could not determine"))
|
|
50
68
|
throw error;
|
|
51
|
-
|
|
69
|
+
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
|
70
|
+
throw new Error(status === 401 || status === 403
|
|
71
|
+
? `Failed to fetch organization (status ${status}). Your PAT token is invalid, expired or revoked.`
|
|
72
|
+
: `Failed to fetch organization${status ? ` (status ${status})` : ""}. Check TESTKASE_API_BASE_URL and your network.`);
|
|
52
73
|
}
|
|
53
74
|
}
|
|
54
75
|
// ─── API call helper ─────────────────────────────────────────────────────────
|
|
@@ -71,9 +92,16 @@ async function callApi(method, path, options = {}) {
|
|
|
71
92
|
method,
|
|
72
93
|
url,
|
|
73
94
|
headers: getApiHeaders(),
|
|
95
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
74
96
|
...(options.body !== undefined && { data: options.body }),
|
|
75
97
|
});
|
|
76
|
-
|
|
98
|
+
// Some endpoints report "not found" as HTTP 200 with status "error"
|
|
99
|
+
const data = response.data;
|
|
100
|
+
if (data && typeof data === "object" && data.status === "error") {
|
|
101
|
+
const detail = data.message || data.success || data.error || "Request failed";
|
|
102
|
+
throw new Error(`API returned an error: ${String(detail).slice(0, 500)}`);
|
|
103
|
+
}
|
|
104
|
+
return data;
|
|
77
105
|
}
|
|
78
106
|
// ─── Error handler (reused) ──────────────────────────────────────────────────
|
|
79
107
|
function handleApiError(error) {
|
|
@@ -113,10 +141,119 @@ function safeJsonParse(raw, fieldName) {
|
|
|
113
141
|
}
|
|
114
142
|
}
|
|
115
143
|
function validateId(id, fieldName) {
|
|
116
|
-
|
|
144
|
+
const str = String(id);
|
|
145
|
+
if (!/^[a-zA-Z0-9_\-]+$/.test(str)) {
|
|
117
146
|
throw new Error(`Invalid ${fieldName}: contains disallowed characters`);
|
|
118
147
|
}
|
|
119
|
-
return
|
|
148
|
+
return str;
|
|
149
|
+
}
|
|
150
|
+
function toIdList(raw) {
|
|
151
|
+
return Array.isArray(raw) ? raw.join(",") : String(raw);
|
|
152
|
+
}
|
|
153
|
+
function toStringArray(raw) {
|
|
154
|
+
if (Array.isArray(raw))
|
|
155
|
+
return raw.map((v) => String(v).trim()).filter(Boolean);
|
|
156
|
+
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
157
|
+
}
|
|
158
|
+
// Normalise one test step; the backend requires `description` to be a string
|
|
159
|
+
function normalizeStep(s) {
|
|
160
|
+
if (typeof s === "string")
|
|
161
|
+
return { description: s };
|
|
162
|
+
const step = {
|
|
163
|
+
description: s?.description ?? s?.step ?? s?.action ?? "",
|
|
164
|
+
test_data: s?.test_data ?? s?.testData,
|
|
165
|
+
expected_result: s?.expected_result ?? s?.expectedResult ?? s?.expected,
|
|
166
|
+
};
|
|
167
|
+
if (!step.description && !step.test_data && !step.expected_result) {
|
|
168
|
+
throw new Error("Each test step needs at least a description, test_data or expected_result");
|
|
169
|
+
}
|
|
170
|
+
return step;
|
|
171
|
+
}
|
|
172
|
+
function parseSteps(raw) {
|
|
173
|
+
if (raw === undefined || raw === null || raw === "")
|
|
174
|
+
return null;
|
|
175
|
+
const parsed = safeJsonParse(raw, "test_steps");
|
|
176
|
+
if (!Array.isArray(parsed))
|
|
177
|
+
throw new Error("test_steps must be a JSON array");
|
|
178
|
+
return parsed.map(normalizeStep);
|
|
179
|
+
}
|
|
180
|
+
// Steps are added one call each; collect failures instead of aborting
|
|
181
|
+
async function addSteps(projectId, testcaseId, steps) {
|
|
182
|
+
let created = 0;
|
|
183
|
+
const errors = [];
|
|
184
|
+
for (const [i, step] of steps.entries()) {
|
|
185
|
+
try {
|
|
186
|
+
await callApi("post", `projects/testcases/teststeps/${projectId}/${testcaseId}`, { body: step });
|
|
187
|
+
created++;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const err = axios.isAxiosError(error) ? handleApiError(error) : error;
|
|
191
|
+
errors.push(`step ${i + 1}: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { created, errors };
|
|
195
|
+
}
|
|
196
|
+
// The list endpoints only accept array values inside the filters JSON
|
|
197
|
+
function buildFilters(raw, extra = {}) {
|
|
198
|
+
const parsed = raw ? safeJsonParse(raw, "filters") : {};
|
|
199
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
200
|
+
throw new Error("filters must be a JSON object");
|
|
201
|
+
}
|
|
202
|
+
const merged = { ...parsed };
|
|
203
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
204
|
+
if (v !== undefined && v !== null && v !== "")
|
|
205
|
+
merged[k] = v;
|
|
206
|
+
}
|
|
207
|
+
for (const k of Object.keys(merged)) {
|
|
208
|
+
if (k === "custom_fields" || k.startsWith("custom_fields."))
|
|
209
|
+
continue;
|
|
210
|
+
if (!Array.isArray(merged[k]))
|
|
211
|
+
merged[k] = [merged[k]];
|
|
212
|
+
}
|
|
213
|
+
return Object.keys(merged).length ? JSON.stringify(merged) : undefined;
|
|
214
|
+
}
|
|
215
|
+
// Builds the create/bulk-create payload from tool args (shared by both actions)
|
|
216
|
+
async function buildTestcasePayload(projectId, tc) {
|
|
217
|
+
const data = { title: tc.title ?? tc.name };
|
|
218
|
+
const summary = tc.summary ?? tc.description;
|
|
219
|
+
if (summary)
|
|
220
|
+
data.summary = summary;
|
|
221
|
+
for (const f of ["priority", "status", "automated", "platform"]) {
|
|
222
|
+
if (tc[f])
|
|
223
|
+
data[f] = await coerceFieldValue(projectId, f, tc[f]);
|
|
224
|
+
}
|
|
225
|
+
const rawFolderId = tc.folder_id ?? tc.folderId;
|
|
226
|
+
if (rawFolderId !== undefined && rawFolderId !== null && rawFolderId !== "") {
|
|
227
|
+
const num = Number(rawFolderId);
|
|
228
|
+
if (isNaN(num))
|
|
229
|
+
throw new Error("folder_id must be a numeric folder ID");
|
|
230
|
+
data.folder_id = num;
|
|
231
|
+
}
|
|
232
|
+
if (tc.labels)
|
|
233
|
+
data.labels = toStringArray(tc.labels);
|
|
234
|
+
if (tc.features)
|
|
235
|
+
data.features = toStringArray(tc.features);
|
|
236
|
+
if (tc.environment)
|
|
237
|
+
data.environment = toStringArray(tc.environment);
|
|
238
|
+
for (const f of ["precondition", "test_data", "expected_result"]) {
|
|
239
|
+
if (tc[f])
|
|
240
|
+
data[f] = tc[f];
|
|
241
|
+
}
|
|
242
|
+
const ownerId = tc.owner_id ?? tc.ownerId;
|
|
243
|
+
if (ownerId) {
|
|
244
|
+
const num = Number(ownerId);
|
|
245
|
+
data.owner_id = isNaN(num) ? ownerId : num;
|
|
246
|
+
}
|
|
247
|
+
const customRaw = tc.custom_fields ?? tc.customFields;
|
|
248
|
+
if (customRaw) {
|
|
249
|
+
const parsed = safeJsonParse(customRaw, "custom_fields");
|
|
250
|
+
for (const key of Object.keys(parsed)) {
|
|
251
|
+
if (!Array.isArray(parsed[key]))
|
|
252
|
+
parsed[key] = [parsed[key]];
|
|
253
|
+
}
|
|
254
|
+
data.custom_fields = parsed;
|
|
255
|
+
}
|
|
256
|
+
return data;
|
|
120
257
|
}
|
|
121
258
|
function jsonResponseWithHints(data, hints) {
|
|
122
259
|
const json = JSON.stringify(data, null, 2);
|
|
@@ -136,7 +273,9 @@ const ERROR_HINTS = [
|
|
|
136
273
|
{ pattern: /folder_id/i,
|
|
137
274
|
hint: "RECOVERY: folder_id must be a numeric folder ID. Call get_project_structure(projectId, include='folders') to list folders with their IDs." },
|
|
138
275
|
{ pattern: /status.*(401|unauthorized)/i,
|
|
139
|
-
hint: "RECOVERY: PAT token may be expired. User needs to regenerate from TestKase
|
|
276
|
+
hint: "RECOVERY: PAT token may be expired. User needs to regenerate it from TestKase > API Keys." },
|
|
277
|
+
{ pattern: /Status: 403/i,
|
|
278
|
+
hint: "RECOVERY: The user's project role does not allow this action (e.g. only owners and project admins can delete folders). If the project belongs to another organization, set TESTKASE_ORGANIZATION_ID." },
|
|
140
279
|
{ pattern: /priority/i,
|
|
141
280
|
hint: "RECOVERY: Priority values are project-specific. Call get_project_structure(projectId, include='field_options') to discover allowed values." },
|
|
142
281
|
];
|
|
@@ -192,6 +331,13 @@ function p(args, ...names) {
|
|
|
192
331
|
}
|
|
193
332
|
return undefined;
|
|
194
333
|
}
|
|
334
|
+
function requireArg(args, name, aliases = []) {
|
|
335
|
+
const value = p(args, name, ...aliases);
|
|
336
|
+
if (value === undefined || value === null || value === "") {
|
|
337
|
+
throw new Error(`${name} is required for this action`);
|
|
338
|
+
}
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
195
341
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
196
342
|
// TOOL DEFINITIONS (11 tools)
|
|
197
343
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -199,12 +345,12 @@ const LIST_PROJECTS_TOOL = {
|
|
|
199
345
|
name: "list_projects",
|
|
200
346
|
description: "List all projects the authenticated user has access to. " +
|
|
201
347
|
"Use this first to find a projectId, then pass it to other tools.",
|
|
348
|
+
annotations: { readOnlyHint: true },
|
|
202
349
|
inputSchema: {
|
|
203
350
|
type: "object",
|
|
204
351
|
properties: {
|
|
205
|
-
search: { type: "string", description: "Search by project name or ID" },
|
|
206
352
|
page: { type: "number", description: "Page number (default: 1)" },
|
|
207
|
-
limit: { type: "number", description: "Items per page (default:
|
|
353
|
+
limit: { type: "number", description: "Items per page (default: 12)" },
|
|
208
354
|
},
|
|
209
355
|
required: [],
|
|
210
356
|
},
|
|
@@ -213,6 +359,7 @@ const GET_PROJECT_STRUCTURE_TOOL = {
|
|
|
213
359
|
name: "get_project_structure",
|
|
214
360
|
description: "Get the structure of a project: folders, labels, team members, and field options (allowed values for priority, status, etc.). " +
|
|
215
361
|
"IMPORTANT: Call this before create/update to discover allowed field values, which are project-specific.",
|
|
362
|
+
annotations: { readOnlyHint: true },
|
|
216
363
|
inputSchema: {
|
|
217
364
|
type: "object",
|
|
218
365
|
properties: {
|
|
@@ -232,18 +379,24 @@ const GET_PROJECT_STRUCTURE_TOOL = {
|
|
|
232
379
|
};
|
|
233
380
|
const SEARCH_TESTCASES_TOOL = {
|
|
234
381
|
name: "search_testcases",
|
|
235
|
-
description: "Search and list test cases in a project. Supports text search, filters, sorting, and pagination.",
|
|
382
|
+
description: "Search and list test cases in a project. Supports text search, folder filter, filters, sorting, and pagination.",
|
|
383
|
+
annotations: { readOnlyHint: true },
|
|
236
384
|
inputSchema: {
|
|
237
385
|
type: "object",
|
|
238
386
|
properties: {
|
|
239
387
|
projectId: { type: "string", description: "Project ID" },
|
|
240
|
-
search: { type: "string", description: "Search text (title
|
|
388
|
+
search: { type: "string", description: "Search text (ID, title, summary, field values)" },
|
|
389
|
+
folder_id: {
|
|
390
|
+
type: "number",
|
|
391
|
+
description: "Only test cases in this folder (sub-folders included). Use 0 for test cases with no folder.",
|
|
392
|
+
},
|
|
241
393
|
filters: {
|
|
242
394
|
type: "string",
|
|
243
|
-
description: 'JSON
|
|
395
|
+
description: 'JSON object of filters; every value is a list. Keys are field names (use the field_options names). ' +
|
|
396
|
+
'Example: {"status":["Active"],"priority":["High","Critical"]}. Also supports "exclude_status".',
|
|
244
397
|
},
|
|
245
398
|
page: { type: "number", description: "Page number (default: 1)" },
|
|
246
|
-
limit: { type: "number", description: "Items per page (default: 20)" },
|
|
399
|
+
limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
|
|
247
400
|
sortBy: { type: "string", description: "Field to sort by (e.g. created_at, title)" },
|
|
248
401
|
sortOrder: { type: "string", enum: ["asc", "desc"], description: "Sort direction" },
|
|
249
402
|
},
|
|
@@ -252,7 +405,8 @@ const SEARCH_TESTCASES_TOOL = {
|
|
|
252
405
|
};
|
|
253
406
|
const GET_TESTCASE_TOOL = {
|
|
254
407
|
name: "get_testcase",
|
|
255
|
-
description: "Get full details of a single test case including test steps and attachments.",
|
|
408
|
+
description: "Get full details of a single test case including test steps (with step ids) and attachments.",
|
|
409
|
+
annotations: { readOnlyHint: true },
|
|
256
410
|
inputSchema: {
|
|
257
411
|
type: "object",
|
|
258
412
|
properties: {
|
|
@@ -262,27 +416,34 @@ const GET_TESTCASE_TOOL = {
|
|
|
262
416
|
required: ["projectId", "testcaseId"],
|
|
263
417
|
},
|
|
264
418
|
};
|
|
419
|
+
const TESTCASE_FIELDS = "title, summary, precondition, priority, status, automated, test_data, expected_result, labels, features, platform, environment, custom_fields, owner_id, folder_id";
|
|
265
420
|
const MANAGE_TESTCASE_TOOL = {
|
|
266
421
|
name: "manage_testcase",
|
|
267
422
|
description: "IMPORTANT: Before creating/updating, call get_project_structure(projectId) to discover folder IDs and allowed field values.\n\n" +
|
|
268
|
-
"Create,
|
|
423
|
+
"Create, update or delete test cases and their test steps.\n\n" +
|
|
269
424
|
"ACTIONS:\n" +
|
|
270
|
-
" create
|
|
271
|
-
" create_bulk
|
|
272
|
-
" update
|
|
273
|
-
" delete
|
|
274
|
-
"
|
|
425
|
+
" create - Create a single test case. Params: title (required), test_steps, and any of: " + TESTCASE_FIELDS + "\n" +
|
|
426
|
+
" create_bulk - Create up to 100 test cases in one go. Params: testcases (JSON array; each item takes the same fields as create, including test_steps)\n" +
|
|
427
|
+
" update - Update one field for test case(s). Params: ids, field, value (all required)\n" +
|
|
428
|
+
" delete - PERMANENTLY delete test case(s) with their steps, history and links. Params: ids (required)\n" +
|
|
429
|
+
" add_steps - Append steps to a test case. Params: testcaseId, test_steps\n" +
|
|
430
|
+
" update_step - Change one step. Params: testcaseId, step_id, and description/test_data/expected_result\n" +
|
|
431
|
+
" delete_steps - Delete steps. Params: testcaseId, step_ids (comma-separated)\n" +
|
|
432
|
+
" reorder_steps - Set the step order. Params: testcaseId, step_ids (all step ids, in the new order)\n" +
|
|
433
|
+
"Step ids come from get_testcase.\n\n" +
|
|
434
|
+
"VALID UPDATE FIELDS: " + TESTCASE_FIELDS + "\n\n" +
|
|
275
435
|
"EXAMPLES:\n" +
|
|
276
|
-
" Create: { action: 'create', title: 'Verify login' }\n" +
|
|
436
|
+
" Create: { action: 'create', title: 'Verify login', test_steps: '[{\"description\":\"Open login page\",\"expected_result\":\"Form shown\"}]' }\n" +
|
|
277
437
|
" Update: { action: 'update', ids: 'TEST-1', field: 'priority', value: '<value from get_project_structure>' }\n" +
|
|
278
438
|
" Delete: { action: 'delete', ids: 'TEST-1,TEST-2' }",
|
|
439
|
+
annotations: { destructiveHint: true },
|
|
279
440
|
inputSchema: {
|
|
280
441
|
type: "object",
|
|
281
442
|
properties: {
|
|
282
443
|
projectId: { type: "string", description: "Project ID" },
|
|
283
444
|
action: {
|
|
284
445
|
type: "string",
|
|
285
|
-
enum: ["create", "create_bulk", "update", "delete"],
|
|
446
|
+
enum: ["create", "create_bulk", "update", "delete", "add_steps", "update_step", "delete_steps", "reorder_steps"],
|
|
286
447
|
description: "Action to perform",
|
|
287
448
|
},
|
|
288
449
|
// create params
|
|
@@ -292,14 +453,14 @@ const MANAGE_TESTCASE_TOOL = {
|
|
|
292
453
|
type: "string",
|
|
293
454
|
description: "[create] Priority. Values are project-specific — call get_project_structure(projectId, include='field_options') first to discover allowed values.",
|
|
294
455
|
},
|
|
295
|
-
folder_id: { type: "
|
|
456
|
+
folder_id: { type: "number", description: "[create] Folder ID to place the test case in" },
|
|
296
457
|
labels: {
|
|
297
458
|
type: "string",
|
|
298
459
|
description: "[create] Comma-separated label names",
|
|
299
460
|
},
|
|
300
461
|
test_steps: {
|
|
301
462
|
type: "string",
|
|
302
|
-
description: '[create] JSON array of test steps: [{"description":"...","test_data":"...","expected_result":"..."}]',
|
|
463
|
+
description: '[create/add_steps] JSON array of test steps: [{"description":"...","test_data":"...","expected_result":"..."}]. Every step needs a description.',
|
|
303
464
|
},
|
|
304
465
|
precondition: { type: "string", description: "[create] Preconditions for the test case" },
|
|
305
466
|
status: {
|
|
@@ -310,15 +471,15 @@ const MANAGE_TESTCASE_TOOL = {
|
|
|
310
471
|
type: "string",
|
|
311
472
|
description: "[create] Automation status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
|
|
312
473
|
},
|
|
313
|
-
test_data: { type: "string", description: "[create] Test data
|
|
314
|
-
expected_result: { type: "string", description: "[create] Expected result
|
|
474
|
+
test_data: { type: "string", description: "[create/update_step] Test data" },
|
|
475
|
+
expected_result: { type: "string", description: "[create/update_step] Expected result" },
|
|
315
476
|
platform: {
|
|
316
477
|
type: "string",
|
|
317
478
|
description: "[create] Platform. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
|
|
318
479
|
},
|
|
319
480
|
environment: { type: "string", description: "[create] Comma-separated environment values" },
|
|
320
481
|
features: { type: "string", description: "[create] Comma-separated feature/component names" },
|
|
321
|
-
owner_id: { type: "string", description: "[create] Owner user ID" },
|
|
482
|
+
owner_id: { type: "string", description: "[create] Owner user ID (a project member)" },
|
|
322
483
|
custom_fields: {
|
|
323
484
|
type: "string",
|
|
324
485
|
description: '[create] JSON object of custom field values (e.g. {"custom1": "value", "custom2": ["val1","val2"]})',
|
|
@@ -326,7 +487,8 @@ const MANAGE_TESTCASE_TOOL = {
|
|
|
326
487
|
// create_bulk params
|
|
327
488
|
testcases: {
|
|
328
489
|
type: "string",
|
|
329
|
-
description: "[create_bulk] JSON array of test cases
|
|
490
|
+
description: "[create_bulk] JSON array of test cases. Each item takes the create fields, e.g. " +
|
|
491
|
+
'[{"title":"...","folder_id":12,"priority":"High","test_steps":[{"description":"...","expected_result":"..."}]}]',
|
|
330
492
|
},
|
|
331
493
|
// update/delete params
|
|
332
494
|
ids: {
|
|
@@ -335,9 +497,15 @@ const MANAGE_TESTCASE_TOOL = {
|
|
|
335
497
|
},
|
|
336
498
|
field: {
|
|
337
499
|
type: "string",
|
|
338
|
-
|
|
500
|
+
enum: TESTCASE_FIELDS.split(", "),
|
|
501
|
+
description: "[update] Field to update. For priority/status/automated/platform/environment, call get_project_structure(include='field_options') to get allowed values.",
|
|
339
502
|
},
|
|
340
503
|
value: { type: "string", description: "[update] New value. String for most fields. For labels/features/environment: comma-separated. For custom_fields: JSON object (e.g. {\"fieldName\": \"value\"})" },
|
|
504
|
+
// step params
|
|
505
|
+
testcaseId: { type: "string", description: "[add_steps/update_step/delete_steps/reorder_steps] Test case ID (e.g. TEST-123)" },
|
|
506
|
+
step_id: { type: "number", description: "[update_step] Step ID to change" },
|
|
507
|
+
description: { type: "string", description: "[update_step] Step description" },
|
|
508
|
+
step_ids: { type: "string", description: "[delete_steps/reorder_steps] Comma-separated step IDs" },
|
|
341
509
|
},
|
|
342
510
|
required: ["projectId", "action"],
|
|
343
511
|
},
|
|
@@ -348,8 +516,10 @@ const MANAGE_FOLDER_TOOL = {
|
|
|
348
516
|
"ACTIONS:\n" +
|
|
349
517
|
" create - Create a folder. Params: name (required), section, parentId\n" +
|
|
350
518
|
" rename - Rename a folder. Params: folderId (required), name (required)\n" +
|
|
351
|
-
" move - Move a folder. Params: folderId (required), parentId (null for root)\n" +
|
|
352
|
-
" delete -
|
|
519
|
+
" move - Move a folder. Params: folderId (required), parentId (omit or null for root)\n" +
|
|
520
|
+
" delete - PERMANENTLY delete a folder, all its sub-folders AND every test case / cycle / plan inside them. " +
|
|
521
|
+
"Only owners and project admins can do this. Confirm with the user first. Params: folderId (required)",
|
|
522
|
+
annotations: { destructiveHint: true },
|
|
353
523
|
inputSchema: {
|
|
354
524
|
type: "object",
|
|
355
525
|
properties: {
|
|
@@ -380,15 +550,16 @@ const MANAGE_FOLDER_TOOL = {
|
|
|
380
550
|
const SEARCH_TEST_CYCLES_TOOL = {
|
|
381
551
|
name: "search_test_cycles",
|
|
382
552
|
description: "List and search test cycles in a project with execution progress.",
|
|
553
|
+
annotations: { readOnlyHint: true },
|
|
383
554
|
inputSchema: {
|
|
384
555
|
type: "object",
|
|
385
556
|
properties: {
|
|
386
557
|
projectId: { type: "string", description: "Project ID" },
|
|
387
558
|
search: { type: "string", description: "Search by title" },
|
|
388
559
|
folderId: { type: "number", description: "Filter by folder ID" },
|
|
389
|
-
status: { type: "string", description: "Filter by status
|
|
560
|
+
status: { type: "string", enum: ["open", "in_progress", "completed"], description: "Filter by status" },
|
|
390
561
|
page: { type: "number", description: "Page number (default: 1)" },
|
|
391
|
-
limit: { type: "number", description: "Items per page (default: 20)" },
|
|
562
|
+
limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
|
|
392
563
|
},
|
|
393
564
|
required: ["projectId"],
|
|
394
565
|
},
|
|
@@ -406,6 +577,7 @@ const MANAGE_TEST_CYCLE_TOOL = {
|
|
|
406
577
|
" unlink_testcases - Unlink test cases from cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
|
|
407
578
|
" assign_testcases - Assign test cases to user. Params: cycleId (required), testcase_ids (required), assignee_id (required)\n\n" +
|
|
408
579
|
"VALID STATUSES: open, in_progress, completed",
|
|
580
|
+
annotations: { destructiveHint: true },
|
|
409
581
|
inputSchema: {
|
|
410
582
|
type: "object",
|
|
411
583
|
properties: {
|
|
@@ -434,7 +606,8 @@ const MANAGE_TEST_CYCLE_TOOL = {
|
|
|
434
606
|
summary: { type: "string", description: "[create/update] Description" },
|
|
435
607
|
status: {
|
|
436
608
|
type: "string",
|
|
437
|
-
|
|
609
|
+
enum: ["open", "in_progress", "completed"],
|
|
610
|
+
description: "[create/update] Status",
|
|
438
611
|
},
|
|
439
612
|
planned_start_date: {
|
|
440
613
|
type: "string",
|
|
@@ -474,7 +647,8 @@ const EXECUTE_TESTS_TOOL = {
|
|
|
474
647
|
name: "execute_tests",
|
|
475
648
|
description: "Record test execution results for test cases in a test cycle. " +
|
|
476
649
|
"Pass a single result or an array of results. " +
|
|
477
|
-
"Valid execution statuses: pass, fail, blocked, not_executed."
|
|
650
|
+
"Valid execution statuses: pass, fail, blocked, not_executed. " +
|
|
651
|
+
"The test cases must already be linked to the cycle (manage_test_cycle link_testcases).",
|
|
478
652
|
inputSchema: {
|
|
479
653
|
type: "object",
|
|
480
654
|
properties: {
|
|
@@ -485,6 +659,12 @@ const EXECUTE_TESTS_TOOL = {
|
|
|
485
659
|
description: 'JSON array of results: [{"testcase_id":"TEST-1","execution_status":"pass","actual_result":"Worked","environment":"staging"}]. ' +
|
|
486
660
|
"Each object must have testcase_id and execution_status. actual_result and environment are optional.",
|
|
487
661
|
},
|
|
662
|
+
mode: {
|
|
663
|
+
type: "string",
|
|
664
|
+
enum: ["manual", "automation"],
|
|
665
|
+
description: "manual (default): each result is recorded like a tester setting it in the app — the new status replaces the old one. " +
|
|
666
|
+
"automation: recorded as a CI/automation run, where a failure from any earlier automation run keeps the case failed. Use only for CI results.",
|
|
667
|
+
},
|
|
488
668
|
},
|
|
489
669
|
required: ["projectId", "cycleId", "results"],
|
|
490
670
|
},
|
|
@@ -501,7 +681,9 @@ const MANAGE_TEST_PLAN_TOOL = {
|
|
|
501
681
|
" link_cycles - Link cycles to plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
|
|
502
682
|
" unlink_cycles - Unlink cycles from plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
|
|
503
683
|
" get_cycles - List cycles in plan. Params: planId (required), search, page, limit\n" +
|
|
504
|
-
" get_testcases - List test cases in plan. Params: planId (required), search, page, limit"
|
|
684
|
+
" get_testcases - List test cases in plan. Params: planId (required), search, page, limit\n\n" +
|
|
685
|
+
"VALID STATUSES: open, in_progress, completed",
|
|
686
|
+
annotations: { destructiveHint: true },
|
|
505
687
|
inputSchema: {
|
|
506
688
|
type: "object",
|
|
507
689
|
properties: {
|
|
@@ -529,7 +711,7 @@ const MANAGE_TEST_PLAN_TOOL = {
|
|
|
529
711
|
// create/update fields
|
|
530
712
|
title: { type: "string", description: "[create/update] Plan title" },
|
|
531
713
|
summary: { type: "string", description: "[create/update] Description" },
|
|
532
|
-
status: { type: "string", description: "[create/update] Status" },
|
|
714
|
+
status: { type: "string", enum: ["open", "in_progress", "completed"], description: "[create/update] Status" },
|
|
533
715
|
planned_start_date: {
|
|
534
716
|
type: "string",
|
|
535
717
|
description: "[create/update] Start date (YYYY-MM-DD)",
|
|
@@ -559,7 +741,8 @@ const MANAGE_TEST_PLAN_TOOL = {
|
|
|
559
741
|
};
|
|
560
742
|
const GET_REPORT_TOOL = {
|
|
561
743
|
name: "get_report",
|
|
562
|
-
description: "Get reports and analytics for a project. Covers execution, coverage, trends, team, defects, and
|
|
744
|
+
description: "Get reports and analytics for a project. Covers execution, coverage, trends, team, defects, and insights " +
|
|
745
|
+
"(the insight reports are computed from your data; they do not use AI credits). " +
|
|
563
746
|
"Available report types: execution_summary, execution_by_cycle, execution_by_tester, execution_by_priority, " +
|
|
564
747
|
"execution_by_environment, execution_by_folder, requirement_coverage, traceability_matrix, " +
|
|
565
748
|
"failed_requirements, uncovered_requirements, testcase_coverage, unlinked_testcases, " +
|
|
@@ -571,6 +754,7 @@ const GET_REPORT_TOOL = {
|
|
|
571
754
|
"testcase_quality, predictive_failure, smart_prioritization, tester_effectiveness, " +
|
|
572
755
|
"stale_tests, defect_hotspots, cycle_health, suite_optimization, " +
|
|
573
756
|
"requirement_risk_matrix, execution_velocity, project_health.",
|
|
757
|
+
annotations: { readOnlyHint: true },
|
|
574
758
|
inputSchema: {
|
|
575
759
|
type: "object",
|
|
576
760
|
properties: {
|
|
@@ -579,14 +763,15 @@ const GET_REPORT_TOOL = {
|
|
|
579
763
|
type: "string",
|
|
580
764
|
description: "Report type (see description for full list)",
|
|
581
765
|
},
|
|
582
|
-
|
|
766
|
+
test_cycle_id: {
|
|
583
767
|
type: "string",
|
|
584
|
-
description: "
|
|
768
|
+
description: "Only this test cycle (one ID, e.g. TCYCLE-3)",
|
|
585
769
|
},
|
|
586
|
-
|
|
770
|
+
test_plan_id: {
|
|
587
771
|
type: "string",
|
|
588
|
-
description: "
|
|
772
|
+
description: "Only this test plan (one ID, e.g. TPLAN-2)",
|
|
589
773
|
},
|
|
774
|
+
folder_id: { type: "number", description: "Only this folder" },
|
|
590
775
|
startDate: { type: "string", description: "Start date filter (YYYY-MM-DD)" },
|
|
591
776
|
endDate: { type: "string", description: "End date filter (YYYY-MM-DD)" },
|
|
592
777
|
granularity: {
|
|
@@ -663,7 +848,6 @@ const REPORT_TYPE_MAP = {
|
|
|
663
848
|
async function handleListProjects(args) {
|
|
664
849
|
const data = await callApi("get", "projects/user/my-projects", {
|
|
665
850
|
query: {
|
|
666
|
-
search: p(args, "search"),
|
|
667
851
|
page: p(args, "page"),
|
|
668
852
|
limit: p(args, "limit"),
|
|
669
853
|
},
|
|
@@ -717,10 +901,17 @@ async function handleSearchTestcases(args) {
|
|
|
717
901
|
if (!projectId)
|
|
718
902
|
throw new Error("projectId is required");
|
|
719
903
|
validateId(projectId, "projectId");
|
|
904
|
+
const folderId = p(args, "folder_id", "folderId");
|
|
905
|
+
if (folderId !== undefined && folderId !== null && folderId !== "" && isNaN(Number(folderId))) {
|
|
906
|
+
throw new Error("folder_id must be a numeric folder ID");
|
|
907
|
+
}
|
|
908
|
+
const filters = buildFilters(p(args, "filters"), {
|
|
909
|
+
folder_id: folderId === undefined || folderId === null || folderId === "" ? undefined : [Number(folderId)],
|
|
910
|
+
});
|
|
720
911
|
const data = await callApi("get", `projects/testcases/get-testcase/${projectId}`, {
|
|
721
912
|
query: {
|
|
722
913
|
search: p(args, "search"),
|
|
723
|
-
filters
|
|
914
|
+
filters,
|
|
724
915
|
page: p(args, "page"),
|
|
725
916
|
limit: p(args, "limit"),
|
|
726
917
|
sortBy: p(args, "sortBy", "sort_by"),
|
|
@@ -767,158 +958,71 @@ async function handleManageTestcase(args) {
|
|
|
767
958
|
const title = p(args, "title", "name");
|
|
768
959
|
if (!title)
|
|
769
960
|
throw new Error("title is required for create action");
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
if (p(args, "priority"))
|
|
774
|
-
testcaseData.priority = await coerceFieldValue(projectId, "priority", p(args, "priority"));
|
|
775
|
-
const rawFolderId = p(args, "folder_id", "folderId");
|
|
776
|
-
if (rawFolderId) {
|
|
777
|
-
const numFolderId = Number(rawFolderId);
|
|
778
|
-
testcaseData.folder_id = isNaN(numFolderId) ? rawFolderId : numFolderId;
|
|
779
|
-
}
|
|
780
|
-
const labelsRaw = p(args, "labels");
|
|
781
|
-
if (labelsRaw) {
|
|
782
|
-
testcaseData.labels =
|
|
783
|
-
typeof labelsRaw === "string"
|
|
784
|
-
? labelsRaw.split(",").map((l) => l.trim()).filter(Boolean)
|
|
785
|
-
: labelsRaw;
|
|
786
|
-
}
|
|
787
|
-
if (p(args, "precondition"))
|
|
788
|
-
testcaseData.precondition = p(args, "precondition");
|
|
789
|
-
if (p(args, "status"))
|
|
790
|
-
testcaseData.status = await coerceFieldValue(projectId, "status", p(args, "status"));
|
|
791
|
-
if (p(args, "automated"))
|
|
792
|
-
testcaseData.automated = await coerceFieldValue(projectId, "automated", p(args, "automated"));
|
|
793
|
-
if (p(args, "test_data"))
|
|
794
|
-
testcaseData.test_data = p(args, "test_data");
|
|
795
|
-
if (p(args, "expected_result"))
|
|
796
|
-
testcaseData.expected_result = p(args, "expected_result");
|
|
797
|
-
if (p(args, "platform"))
|
|
798
|
-
testcaseData.platform = await coerceFieldValue(projectId, "platform", p(args, "platform"));
|
|
799
|
-
const featuresRaw = p(args, "features");
|
|
800
|
-
if (featuresRaw) {
|
|
801
|
-
testcaseData.features = typeof featuresRaw === "string"
|
|
802
|
-
? featuresRaw.split(",").map((s) => s.trim())
|
|
803
|
-
: featuresRaw;
|
|
804
|
-
}
|
|
805
|
-
const envRaw = p(args, "environment");
|
|
806
|
-
if (envRaw) {
|
|
807
|
-
testcaseData.environment = typeof envRaw === "string"
|
|
808
|
-
? envRaw.split(",").map((s) => s.trim())
|
|
809
|
-
: envRaw;
|
|
810
|
-
}
|
|
811
|
-
const ownerId = p(args, "owner_id", "ownerId");
|
|
812
|
-
if (ownerId) {
|
|
813
|
-
const num = Number(ownerId);
|
|
814
|
-
testcaseData.owner_id = isNaN(num) ? ownerId : num;
|
|
815
|
-
}
|
|
816
|
-
const customFieldsRaw = p(args, "custom_fields", "customFields");
|
|
817
|
-
if (customFieldsRaw) {
|
|
818
|
-
const parsed = safeJsonParse(customFieldsRaw, "custom_fields");
|
|
819
|
-
for (const key of Object.keys(parsed)) {
|
|
820
|
-
if (!Array.isArray(parsed[key])) {
|
|
821
|
-
parsed[key] = [parsed[key]];
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
testcaseData.custom_fields = parsed;
|
|
825
|
-
}
|
|
826
|
-
// Parse test_steps
|
|
827
|
-
const stepsRaw = p(args, "test_steps", "testSteps", "steps");
|
|
828
|
-
const testSteps = stepsRaw ? safeJsonParse(stepsRaw, "test_steps") : null;
|
|
829
|
-
// Step 1: Create testcase
|
|
961
|
+
// Validate steps before creating anything, so a bad step can't leave a half-made test case
|
|
962
|
+
const testSteps = parseSteps(p(args, "test_steps", "testSteps", "steps"));
|
|
963
|
+
const testcaseData = await buildTestcasePayload(projectId, { ...args, title });
|
|
830
964
|
const createResult = await callApi("post", `projects/testcases/create-testcase/${projectId}`, { body: testcaseData });
|
|
831
|
-
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
// Backend endpoint accepts a single step per call, so loop through each step
|
|
836
|
-
for (const s of testSteps) {
|
|
837
|
-
const step = {
|
|
838
|
-
description: s.description || s.step,
|
|
839
|
-
test_data: s.test_data,
|
|
840
|
-
expected_result: s.expected_result,
|
|
841
|
-
};
|
|
842
|
-
await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, {
|
|
843
|
-
body: step,
|
|
844
|
-
});
|
|
845
|
-
}
|
|
846
|
-
}
|
|
965
|
+
const tcId = createResult?.testcaseId || createResult?.data?.id;
|
|
966
|
+
const hints = [
|
|
967
|
+
"To update fields, use manage_testcase(action='update', ids='<id>', field='<name>', value='<value>')",
|
|
968
|
+
];
|
|
847
969
|
if (tcId) {
|
|
848
|
-
|
|
849
|
-
if (
|
|
850
|
-
|
|
970
|
+
createResult.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${tcId}`;
|
|
971
|
+
if (testSteps?.length) {
|
|
972
|
+
const steps = await addSteps(projectId, tcId, testSteps);
|
|
973
|
+
createResult.steps_created = steps.created;
|
|
974
|
+
if (steps.errors.length) {
|
|
975
|
+
createResult.step_errors = steps.errors;
|
|
976
|
+
hints.push(`The test case ${tcId} WAS created; do not create it again. Add the missing steps with manage_testcase(action='add_steps', testcaseId='${tcId}').`);
|
|
977
|
+
}
|
|
851
978
|
}
|
|
852
979
|
}
|
|
853
|
-
return jsonResponseWithHints(createResult,
|
|
854
|
-
"To update fields, use manage_testcase(action='update', ids='<id>', field='<name>', value='<value>')",
|
|
855
|
-
]);
|
|
980
|
+
return jsonResponseWithHints(createResult, hints);
|
|
856
981
|
}
|
|
857
982
|
case "create_bulk": {
|
|
858
983
|
const rawTestcases = p(args, "testcases", "test_cases", "items");
|
|
859
984
|
if (!rawTestcases)
|
|
860
985
|
throw new Error("testcases is required for create_bulk action");
|
|
861
986
|
const testcases = safeJsonParse(rawTestcases, "testcases");
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
987
|
+
if (!Array.isArray(testcases) || testcases.length === 0) {
|
|
988
|
+
throw new Error("testcases must be a non-empty JSON array");
|
|
989
|
+
}
|
|
990
|
+
// Validate everything before creating anything
|
|
991
|
+
const items = [];
|
|
992
|
+
for (const [i, tc] of testcases.entries()) {
|
|
993
|
+
if (!tc?.title && !tc?.name)
|
|
994
|
+
throw new Error(`testcases[${i}]: title is required`);
|
|
995
|
+
try {
|
|
996
|
+
items.push({
|
|
997
|
+
testcase: await buildTestcasePayload(projectId, tc),
|
|
998
|
+
test_steps: parseSteps(tc.test_steps ?? tc.testSteps ?? tc.steps),
|
|
999
|
+
});
|
|
873
1000
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
? tc.labels.split(",").map((l) => l.trim()).filter(Boolean)
|
|
877
|
-
: tc.labels;
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
throw new Error(`testcases[${i}]: ${error.message}`);
|
|
878
1003
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
if (tc.status)
|
|
882
|
-
normalized.status = tc.status;
|
|
883
|
-
if (tc.automated)
|
|
884
|
-
normalized.automated = tc.automated;
|
|
885
|
-
return {
|
|
886
|
-
testcase: normalized,
|
|
887
|
-
test_steps: tc.test_steps || tc.testSteps || tc.steps,
|
|
888
|
-
};
|
|
889
|
-
});
|
|
890
|
-
const cleanTestcases = withSteps.map((t) => t.testcase);
|
|
891
|
-
const bulkResult = await callApi("post", `projects/testcases/create-bulk-testcase/${projectId}`, { body: { testcases: cleanTestcases } });
|
|
1004
|
+
}
|
|
1005
|
+
const bulkResult = await callApi("post", `projects/testcases/create-bulk-testcase/${projectId}`, { body: { testcases: items.map((t) => t.testcase) } });
|
|
892
1006
|
// Add test steps for each created testcase
|
|
893
1007
|
const results = bulkResult?.results || [];
|
|
894
1008
|
let stepsCreated = 0;
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
const steps =
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
description: s.description || s.step,
|
|
906
|
-
test_data: s.test_data,
|
|
907
|
-
expected_result: s.expected_result,
|
|
908
|
-
};
|
|
909
|
-
await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, {
|
|
910
|
-
body: step,
|
|
911
|
-
});
|
|
912
|
-
}
|
|
913
|
-
stepsCreated++;
|
|
914
|
-
}
|
|
915
|
-
catch {
|
|
916
|
-
// Non-fatal: steps failed but testcase was created
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
}
|
|
1009
|
+
const stepErrors = {};
|
|
1010
|
+
for (const r of results) {
|
|
1011
|
+
const steps = items[r.index ?? results.indexOf(r)]?.test_steps;
|
|
1012
|
+
const tcId = r.testcaseId || r.id;
|
|
1013
|
+
if (r.status !== "success" || !tcId || !steps?.length)
|
|
1014
|
+
continue;
|
|
1015
|
+
const res = await addSteps(projectId, tcId, steps);
|
|
1016
|
+
stepsCreated += res.created;
|
|
1017
|
+
if (res.errors.length)
|
|
1018
|
+
stepErrors[tcId] = res.errors;
|
|
920
1019
|
}
|
|
921
|
-
|
|
1020
|
+
const response = { ...bulkResult, steps_created: stepsCreated };
|
|
1021
|
+
if (Object.keys(stepErrors).length)
|
|
1022
|
+
response.step_errors = stepErrors;
|
|
1023
|
+
return jsonResponseWithHints(response, Object.keys(stepErrors).length
|
|
1024
|
+
? ["Some steps failed (see step_errors). The test cases WERE created; add the missing steps with manage_testcase(action='add_steps')."]
|
|
1025
|
+
: []);
|
|
922
1026
|
}
|
|
923
1027
|
case "update": {
|
|
924
1028
|
const ids = p(args, "ids", "testcase_ids", "testcaseIds");
|
|
@@ -930,7 +1034,10 @@ async function handleManageTestcase(args) {
|
|
|
930
1034
|
throw new Error("field is required for update action");
|
|
931
1035
|
if (value === undefined)
|
|
932
1036
|
throw new Error("value is required for update action");
|
|
933
|
-
|
|
1037
|
+
if (!TESTCASE_FIELDS.split(", ").includes(field)) {
|
|
1038
|
+
throw new Error(`Invalid field: ${field}. Valid fields: ${TESTCASE_FIELDS}`);
|
|
1039
|
+
}
|
|
1040
|
+
const idsStr = toIdList(ids);
|
|
934
1041
|
// Coerce field values for project-specific fields
|
|
935
1042
|
const coercibleFields = ["priority", "status", "automated", "platform"];
|
|
936
1043
|
// Parse value for fields that expect non-string types
|
|
@@ -964,14 +1071,63 @@ async function handleManageTestcase(args) {
|
|
|
964
1071
|
const ids = p(args, "ids", "testcase_ids", "testcaseIds");
|
|
965
1072
|
if (!ids)
|
|
966
1073
|
throw new Error("ids is required for delete action");
|
|
967
|
-
const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
|
|
968
1074
|
const data = await callApi("delete", `projects/testcases/delete-testcase/${projectId}`, {
|
|
969
|
-
query: { ids:
|
|
1075
|
+
query: { ids: toIdList(ids) },
|
|
1076
|
+
});
|
|
1077
|
+
return jsonResponse(data);
|
|
1078
|
+
}
|
|
1079
|
+
case "add_steps": {
|
|
1080
|
+
const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
|
|
1081
|
+
const steps = parseSteps(p(args, "test_steps", "testSteps", "steps"));
|
|
1082
|
+
if (!steps?.length)
|
|
1083
|
+
throw new Error("test_steps is required for add_steps action");
|
|
1084
|
+
const res = await addSteps(projectId, tcId, steps);
|
|
1085
|
+
if (res.created === 0)
|
|
1086
|
+
throw new Error(`No steps were added:\n${res.errors.join("\n")}`);
|
|
1087
|
+
return jsonResponse({ testcaseId: tcId, steps_created: res.created, step_errors: res.errors });
|
|
1088
|
+
}
|
|
1089
|
+
case "update_step": {
|
|
1090
|
+
const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
|
|
1091
|
+
const stepId = Number(requireArg(args, "step_id", ["stepId"]));
|
|
1092
|
+
if (isNaN(stepId))
|
|
1093
|
+
throw new Error("step_id must be a number");
|
|
1094
|
+
const body = { stepId };
|
|
1095
|
+
for (const f of ["description", "test_data", "expected_result"]) {
|
|
1096
|
+
if (args[f] !== undefined)
|
|
1097
|
+
body[f] = args[f];
|
|
1098
|
+
}
|
|
1099
|
+
// The backend requires a description string even when it is unchanged
|
|
1100
|
+
if (body.description === undefined) {
|
|
1101
|
+
const detail = await callApi("get", `projects/testcases/get-testcase-detail/${projectId}/${tcId}`);
|
|
1102
|
+
const existing = (detail?.data?.steps || []).find((st) => Number(st.id) === stepId);
|
|
1103
|
+
if (!existing)
|
|
1104
|
+
throw new Error(`Step ${stepId} not found on ${tcId}`);
|
|
1105
|
+
body.description = existing.description ?? "";
|
|
1106
|
+
}
|
|
1107
|
+
const data = await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, { body });
|
|
1108
|
+
return jsonResponse(data);
|
|
1109
|
+
}
|
|
1110
|
+
case "delete_steps": {
|
|
1111
|
+
const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
|
|
1112
|
+
const stepIds = toIdList(requireArg(args, "step_ids", ["stepIds"]));
|
|
1113
|
+
const data = await callApi("delete", `projects/testcases/teststeps/${tcId}`, {
|
|
1114
|
+
query: { projectId },
|
|
1115
|
+
body: { stepIds },
|
|
1116
|
+
});
|
|
1117
|
+
return jsonResponse(data);
|
|
1118
|
+
}
|
|
1119
|
+
case "reorder_steps": {
|
|
1120
|
+
const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
|
|
1121
|
+
const stepIds = toStringArray(requireArg(args, "step_ids", ["stepIds"])).map(Number);
|
|
1122
|
+
if (stepIds.some(isNaN))
|
|
1123
|
+
throw new Error("step_ids must be numeric step IDs");
|
|
1124
|
+
const data = await callApi("put", `projects/testcases/teststeps/${projectId}/${tcId}/reorder`, {
|
|
1125
|
+
body: { stepIds },
|
|
970
1126
|
});
|
|
971
1127
|
return jsonResponse(data);
|
|
972
1128
|
}
|
|
973
1129
|
default:
|
|
974
|
-
throw new Error(`Unknown action: ${action}. Use: create, create_bulk, update, delete`);
|
|
1130
|
+
throw new Error(`Unknown action: ${action}. Use: create, create_bulk, update, delete, add_steps, update_step, delete_steps, reorder_steps`);
|
|
975
1131
|
}
|
|
976
1132
|
}
|
|
977
1133
|
// 6. manage_folder
|
|
@@ -998,7 +1154,7 @@ async function handleManageFolder(args) {
|
|
|
998
1154
|
};
|
|
999
1155
|
const parentId = p(args, "parentId", "parent_id");
|
|
1000
1156
|
if (parentId)
|
|
1001
|
-
body.parentId = parentId;
|
|
1157
|
+
body.parentId = Number(parentId);
|
|
1002
1158
|
const data = await callApi("post", "folders/create-folders", { body, skipOrgId: true });
|
|
1003
1159
|
return jsonResponse(data);
|
|
1004
1160
|
}
|
|
@@ -1022,9 +1178,12 @@ async function handleManageFolder(args) {
|
|
|
1022
1178
|
throw new Error("folderId is required for move action");
|
|
1023
1179
|
validateId(folderId, "folderId");
|
|
1024
1180
|
const parentId = p(args, "parentId", "parent_id");
|
|
1181
|
+
const newParentId = parentId === undefined || parentId === null || parentId === "" ? null : Number(parentId);
|
|
1182
|
+
if (newParentId !== null && isNaN(newParentId))
|
|
1183
|
+
throw new Error("parentId must be a numeric folder ID");
|
|
1025
1184
|
const data = await callApi("patch", `folders/${folderId}/move`, {
|
|
1026
1185
|
query: { projectId, section },
|
|
1027
|
-
body: {
|
|
1186
|
+
body: { newParentId },
|
|
1028
1187
|
});
|
|
1029
1188
|
return jsonResponse(data);
|
|
1030
1189
|
}
|
|
@@ -1048,11 +1207,15 @@ async function handleSearchTestCycles(args) {
|
|
|
1048
1207
|
if (!projectId)
|
|
1049
1208
|
throw new Error("projectId is required");
|
|
1050
1209
|
validateId(projectId, "projectId");
|
|
1210
|
+
const folderId = p(args, "folderId", "folder_id");
|
|
1211
|
+
const filters = buildFilters(undefined, {
|
|
1212
|
+
status: p(args, "status"),
|
|
1213
|
+
folder_id: folderId === undefined || folderId === null || folderId === "" ? undefined : Number(folderId),
|
|
1214
|
+
});
|
|
1051
1215
|
const data = await callApi("get", `projects/test-cycles/get-test-cycles/${projectId}`, {
|
|
1052
1216
|
query: {
|
|
1053
1217
|
search: p(args, "search"),
|
|
1054
|
-
|
|
1055
|
-
status: p(args, "status"),
|
|
1218
|
+
filters,
|
|
1056
1219
|
page: p(args, "page"),
|
|
1057
1220
|
limit: p(args, "limit"),
|
|
1058
1221
|
},
|
|
@@ -1060,8 +1223,9 @@ async function handleSearchTestCycles(args) {
|
|
|
1060
1223
|
const baseUrl = getFrontendBaseUrl();
|
|
1061
1224
|
if (Array.isArray(data?.data)) {
|
|
1062
1225
|
data.data.forEach((cycle) => {
|
|
1063
|
-
|
|
1064
|
-
|
|
1226
|
+
const id = cycle.test_cycle_id || cycle.id;
|
|
1227
|
+
if (id)
|
|
1228
|
+
cycle.url = `${baseUrl}/${projectId}/testcycles/${id}`;
|
|
1065
1229
|
});
|
|
1066
1230
|
}
|
|
1067
1231
|
return jsonResponse(data);
|
|
@@ -1090,7 +1254,7 @@ async function handleManageTestCycle(args) {
|
|
|
1090
1254
|
if (p(args, "planned_end_date", "plannedEndDate"))
|
|
1091
1255
|
body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
|
|
1092
1256
|
if (p(args, "folder_id", "folderId"))
|
|
1093
|
-
body.folder_id = p(args, "folder_id", "folderId");
|
|
1257
|
+
body.folder_id = Number(p(args, "folder_id", "folderId"));
|
|
1094
1258
|
const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
|
|
1095
1259
|
return jsonResponse(data);
|
|
1096
1260
|
}
|
|
@@ -1110,7 +1274,7 @@ async function handleManageTestCycle(args) {
|
|
|
1110
1274
|
if (p(args, "planned_end_date", "plannedEndDate"))
|
|
1111
1275
|
body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
|
|
1112
1276
|
if (p(args, "folder_id", "folderId"))
|
|
1113
|
-
body.folder_id = p(args, "folder_id", "folderId");
|
|
1277
|
+
body.folder_id = Number(p(args, "folder_id", "folderId"));
|
|
1114
1278
|
const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
|
|
1115
1279
|
return jsonResponse(data);
|
|
1116
1280
|
}
|
|
@@ -1207,40 +1371,54 @@ async function handleExecuteTests(args) {
|
|
|
1207
1371
|
if (!Array.isArray(results) || results.length === 0) {
|
|
1208
1372
|
throw new Error("results must be a non-empty array");
|
|
1209
1373
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
throw new Error("testcase_id is required in each result");
|
|
1214
|
-
}
|
|
1215
|
-
if (!r.execution_status && !r.executionStatus) {
|
|
1216
|
-
throw new Error("execution_status is required in each result. Valid values: pass, fail, blocked, not_executed");
|
|
1217
|
-
}
|
|
1374
|
+
const mode = p(args, "mode") || "manual";
|
|
1375
|
+
if (mode !== "manual" && mode !== "automation") {
|
|
1376
|
+
throw new Error("mode must be 'manual' or 'automation'");
|
|
1218
1377
|
}
|
|
1219
|
-
//
|
|
1220
|
-
|
|
1221
|
-
const r = results[0];
|
|
1378
|
+
// Validate all results before recording any
|
|
1379
|
+
const normalized = results.map((r, i) => {
|
|
1222
1380
|
const testcaseId = r.testcase_id || r.testcaseId;
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1381
|
+
const status = r.execution_status || r.executionStatus;
|
|
1382
|
+
if (!testcaseId)
|
|
1383
|
+
throw new Error(`results[${i}]: testcase_id is required`);
|
|
1384
|
+
if (!status) {
|
|
1385
|
+
throw new Error(`results[${i}]: execution_status is required. Valid values: pass, fail, blocked, not_executed`);
|
|
1386
|
+
}
|
|
1387
|
+
const out = {
|
|
1388
|
+
testcase_id: validateId(testcaseId, "testcase_id"),
|
|
1389
|
+
execution_status: status,
|
|
1225
1390
|
};
|
|
1226
|
-
|
|
1227
|
-
|
|
1391
|
+
const actual = r.actual_result || r.actualResult;
|
|
1392
|
+
if (actual)
|
|
1393
|
+
out.actual_result = actual;
|
|
1228
1394
|
if (r.environment)
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1395
|
+
out.environment = r.environment;
|
|
1396
|
+
return out;
|
|
1397
|
+
});
|
|
1398
|
+
if (mode === "automation") {
|
|
1399
|
+
const data = await callApi("post", `projects/test-cycles/execute-bulk/${projectId}/${cycleId}`, { body: { results: normalized } });
|
|
1400
|
+
const errors = data?.data?.errors || data?.errors;
|
|
1401
|
+
return jsonResponseWithHints(data, Array.isArray(errors) && errors.length
|
|
1402
|
+
? ["Some results were NOT recorded (see errors) — e.g. test cases not linked to this cycle."]
|
|
1403
|
+
: []);
|
|
1232
1404
|
}
|
|
1233
|
-
//
|
|
1234
|
-
const
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1405
|
+
// Manual: same endpoint the app uses when a tester sets a result
|
|
1406
|
+
const recorded = [];
|
|
1407
|
+
const failed = [];
|
|
1408
|
+
for (const { testcase_id, ...body } of normalized) {
|
|
1409
|
+
try {
|
|
1410
|
+
await callApi("patch", `projects/test-cycles/testcases/update-execution/${projectId}/${cycleId}/${testcase_id}`, { body });
|
|
1411
|
+
recorded.push(testcase_id);
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
const err = axios.isAxiosError(error) ? handleApiError(error) : error;
|
|
1415
|
+
failed.push({ testcase_id, error: err.message });
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (recorded.length === 0) {
|
|
1419
|
+
throw new Error(`No results were recorded:\n${failed.map((f) => `${f.testcase_id}: ${f.error}`).join("\n")}`);
|
|
1420
|
+
}
|
|
1421
|
+
return jsonResponseWithHints({ status: failed.length ? "partial_success" : "success", recorded, failed }, failed.length ? ["Some results were NOT recorded (see failed)."] : []);
|
|
1244
1422
|
}
|
|
1245
1423
|
// 10. manage_test_plan
|
|
1246
1424
|
async function handleManageTestPlan(args) {
|
|
@@ -1266,7 +1444,7 @@ async function handleManageTestPlan(args) {
|
|
|
1266
1444
|
if (p(args, "planned_end_date", "plannedEndDate"))
|
|
1267
1445
|
body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
|
|
1268
1446
|
if (p(args, "folder_id", "folderId"))
|
|
1269
|
-
body.folder_id = p(args, "folder_id", "folderId");
|
|
1447
|
+
body.folder_id = Number(p(args, "folder_id", "folderId"));
|
|
1270
1448
|
const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
|
|
1271
1449
|
return jsonResponse(data);
|
|
1272
1450
|
}
|
|
@@ -1286,7 +1464,7 @@ async function handleManageTestPlan(args) {
|
|
|
1286
1464
|
if (p(args, "planned_end_date", "plannedEndDate"))
|
|
1287
1465
|
body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
|
|
1288
1466
|
if (p(args, "folder_id", "folderId"))
|
|
1289
|
-
body.folder_id = p(args, "folder_id", "folderId");
|
|
1467
|
+
body.folder_id = Number(p(args, "folder_id", "folderId"));
|
|
1290
1468
|
const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
|
|
1291
1469
|
return jsonResponse(data);
|
|
1292
1470
|
}
|
|
@@ -1309,8 +1487,9 @@ async function handleManageTestPlan(args) {
|
|
|
1309
1487
|
const baseUrl = getFrontendBaseUrl();
|
|
1310
1488
|
if (Array.isArray(data?.data)) {
|
|
1311
1489
|
data.data.forEach((plan) => {
|
|
1312
|
-
|
|
1313
|
-
|
|
1490
|
+
const id = plan.test_plan_id || plan.id;
|
|
1491
|
+
if (id)
|
|
1492
|
+
plan.url = `${baseUrl}/${projectId}/testplans/${id}`;
|
|
1314
1493
|
});
|
|
1315
1494
|
}
|
|
1316
1495
|
return jsonResponse(data);
|
|
@@ -1396,15 +1575,26 @@ async function handleGetReport(args) {
|
|
|
1396
1575
|
throw new Error(`Unknown report_type: ${reportType}. Valid types: ${Object.keys(REPORT_TYPE_MAP).join(", ")}`);
|
|
1397
1576
|
}
|
|
1398
1577
|
const query = {};
|
|
1399
|
-
// Common filters
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1578
|
+
// Common filters (the backend takes a single cycle / plan id)
|
|
1579
|
+
const singleId = (value, name) => {
|
|
1580
|
+
if (!value)
|
|
1581
|
+
return undefined;
|
|
1582
|
+
const list = toStringArray(value);
|
|
1583
|
+
if (list.length > 1) {
|
|
1584
|
+
throw new Error(`${name} takes one ID. Call get_report once per ID, or use cycle_comparison for several cycles.`);
|
|
1585
|
+
}
|
|
1586
|
+
return validateId(list[0], name);
|
|
1587
|
+
};
|
|
1588
|
+
query.testCycleId = singleId(p(args, "test_cycle_id", "test_cycle_ids", "testCycleId", "testCycleIds"), "test_cycle_id");
|
|
1589
|
+
query.testPlanId = singleId(p(args, "test_plan_id", "test_plan_ids", "testPlanId", "testPlanIds"), "test_plan_id");
|
|
1590
|
+
if (p(args, "folder_id", "folderId"))
|
|
1591
|
+
query.folderId = String(p(args, "folder_id", "folderId"));
|
|
1592
|
+
if (p(args, "startDate", "start_date", "dateFrom"))
|
|
1593
|
+
query.dateFrom = p(args, "startDate", "start_date", "dateFrom");
|
|
1594
|
+
const endDate = p(args, "endDate", "end_date", "dateTo");
|
|
1595
|
+
// The backend compares with <=, so a bare date would stop at midnight and drop the end day
|
|
1596
|
+
if (endDate)
|
|
1597
|
+
query.dateTo = /^\d{4}-\d{2}-\d{2}$/.test(endDate) ? `${endDate}T23:59:59.999Z` : endDate;
|
|
1408
1598
|
if (p(args, "granularity"))
|
|
1409
1599
|
query.granularity = p(args, "granularity");
|
|
1410
1600
|
// cycle_comparison specific
|
|
@@ -1418,7 +1608,7 @@ async function handleGetReport(args) {
|
|
|
1418
1608
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1419
1609
|
// SERVER SETUP
|
|
1420
1610
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1421
|
-
const server = new Server({ name: "testkase-mcp-server", version:
|
|
1611
|
+
const server = new Server({ name: "testkase-mcp-server", version: VERSION }, { capabilities: { tools: {} } });
|
|
1422
1612
|
// List all 11 tools
|
|
1423
1613
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1424
1614
|
tools: [
|
|
@@ -1479,7 +1669,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1479
1669
|
async function main() {
|
|
1480
1670
|
const transport = new StdioServerTransport();
|
|
1481
1671
|
await server.connect(transport);
|
|
1482
|
-
console.error(
|
|
1672
|
+
console.error(`TestKase MCP Server v${VERSION} running on stdio (11 tools) → ${config.apiBaseUrl}`);
|
|
1483
1673
|
}
|
|
1484
1674
|
main().catch((error) => {
|
|
1485
1675
|
console.error("Fatal error in main():", error);
|