@testkase/test-management-mcp 2.4.1 → 2.6.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
@@ -3,6 +3,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  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
+ import { compact, flattenFolders, pick, shapeList } from "./compact.js";
6
7
  const config = {
7
8
  apiBaseUrl: (process.env.TESTKASE_API_BASE_URL || "https://api.testkase.com").replace(/\/+$/, ""),
8
9
  patToken: process.env.TESTKASE_PAT_TOKEN,
@@ -12,8 +13,8 @@ const config = {
12
13
  : undefined,
13
14
  appUrl: process.env.TESTKASE_APP_URL?.replace(/\/+$/, ""),
14
15
  };
15
- const VERSION = "2.4.1";
16
- const REQUEST_TIMEOUT_MS = 60_000;
16
+ const VERSION = "2.5.0";
17
+ const REQUEST_TIMEOUT_MS = Number(process.env.TESTKASE_TIMEOUT_MS) || 60_000;
17
18
  // ─── Frontend URL helper ─────────────────────────────────────────────────────
18
19
  // Test management lives on its own subdomain
19
20
  const KNOWN_APP_HOSTS = {
@@ -88,13 +89,29 @@ async function callApi(method, path, options = {}) {
88
89
  }
89
90
  const qs = queryParams.toString();
90
91
  const url = `${config.apiBaseUrl}/api/v1/${path}${qs ? `?${qs}` : ""}`;
91
- const response = await axios({
92
+ const send = () => axios({
92
93
  method,
93
94
  url,
94
95
  headers: getApiHeaders(),
95
96
  timeout: REQUEST_TIMEOUT_MS,
96
97
  ...(options.body !== undefined && { data: options.body }),
97
98
  });
99
+ let response;
100
+ try {
101
+ response = await send();
102
+ }
103
+ catch (error) {
104
+ // Reads are safe to repeat: retry once on a dropped connection or 502/503/504. Writes and timeouts never retry.
105
+ const status = axios.isAxiosError(error) ? error.response?.status : undefined;
106
+ const transient = axios.isAxiosError(error) &&
107
+ error.code !== "ECONNABORTED" &&
108
+ error.code !== "ETIMEDOUT" &&
109
+ (!error.response || [502, 503, 504].includes(status));
110
+ if (method !== "get" || !transient)
111
+ throw error;
112
+ await new Promise((r) => setTimeout(r, 500));
113
+ response = await send();
114
+ }
98
115
  // Some endpoints report "not found" as HTTP 200 with status "error"
99
116
  const data = response.data;
100
117
  if (data && typeof data === "object" && data.status === "error") {
@@ -107,6 +124,12 @@ async function callApi(method, path, options = {}) {
107
124
  function handleApiError(error) {
108
125
  if (axios.isAxiosError(error)) {
109
126
  const e = error;
127
+ if (e.code === "ECONNABORTED" || e.code === "ETIMEDOUT") {
128
+ const isRead = (e.config?.method ?? "get").toLowerCase() === "get";
129
+ return new Error(isRead
130
+ ? `Request timed out after ${REQUEST_TIMEOUT_MS} ms. Try a smaller limit or set TESTKASE_TIMEOUT_MS.`
131
+ : `Request timed out after ${REQUEST_TIMEOUT_MS} ms. The change may have been applied: check (search/get) before retrying.`);
132
+ }
110
133
  let msg = `API request failed: ${e.message}`;
111
134
  if (e.response) {
112
135
  msg += `\nStatus: ${e.response.status}`;
@@ -125,11 +148,35 @@ function handleApiError(error) {
125
148
  return error;
126
149
  }
127
150
  // ─── JSON response helper ────────────────────────────────────────────────────
128
- function jsonResponse(data) {
151
+ // Replies are compact JSON (no indentation, no empty values); verbose returns the raw backend data
152
+ function jsonResponse(data, verbose = false) {
129
153
  return {
130
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
154
+ content: [{ type: "text", text: JSON.stringify(verbose ? data : compact(data)) }],
131
155
  };
132
156
  }
157
+ const LIST_KEYS = {
158
+ testcases: ["id", "title", "priority", "status", "automated"],
159
+ cycles: ["test_cycle_id", "title", "status", "progress", "execution_summary", "folder_path"],
160
+ plans: ["test_plan_id", "title", "status", "progress", "test_cycle_count", "testcase_count", "execution_summary", "folder_path"],
161
+ linkedCases: ["test_case_id", "title", "priority", "execution_status", "assignee=assignee.assignee_name"],
162
+ projects: ["project_id", "name", "role"],
163
+ };
164
+ // Lists: chosen fields + paging, unless the caller asks for the raw reply
165
+ function listResponse(args, data, keys, urlIdKey) {
166
+ const verbose = !!p(args, "verbose");
167
+ return jsonResponse(verbose ? data : shapeList(data, keys, urlIdKey), verbose);
168
+ }
169
+ function memberRow(m) {
170
+ const name = String(m.name ?? [m.first_name ?? m.firstName, m.last_name ?? m.lastName].filter(Boolean).join(" ")).trim();
171
+ return { ...pick(m, ["user_id", "id"]), ...(name ? { name } : {}), ...pick(m, ["email", "role"]) };
172
+ }
173
+ const JSON_EXAMPLES = {
174
+ test_steps: '[{"description":"Open the login page","expected_result":"Form is shown"}]',
175
+ testcases: '[{"title":"Login works","test_steps":[{"description":"Open the login page"}]}]',
176
+ results: '[{"testcase_id":"TEST-1","execution_status":"pass"}]',
177
+ filters: '{"status":["Active"]}',
178
+ custom_fields: '{"Browser":"Chrome"}',
179
+ };
133
180
  function safeJsonParse(raw, fieldName) {
134
181
  if (typeof raw !== "string")
135
182
  return raw;
@@ -137,9 +184,37 @@ function safeJsonParse(raw, fieldName) {
137
184
  return JSON.parse(raw);
138
185
  }
139
186
  catch {
140
- throw new Error(`Invalid JSON in ${fieldName}. Ensure the value is valid JSON.`);
187
+ const example = JSON_EXAMPLES[fieldName];
188
+ if (!example)
189
+ throw new Error(`Invalid JSON in ${fieldName}. Ensure the value is valid JSON.`);
190
+ const kind = example.startsWith("[") ? "JSON array" : "JSON object";
191
+ throw new Error(`Invalid JSON in ${fieldName}. Send it as a ${kind} (not inside a string), e.g. ${fieldName}: ${example}`);
141
192
  }
142
193
  }
194
+ // Small models write "Passed", "FAILED", "not run": map them to the API's values
195
+ const EXECUTION_STATUS = {
196
+ pass: "pass", passed: "pass", ok: "pass", success: "pass",
197
+ fail: "fail", failed: "fail", failure: "fail",
198
+ blocked: "blocked", block: "blocked",
199
+ not_executed: "not_executed", not_run: "not_executed", notrun: "not_executed", untested: "not_executed",
200
+ not_tested: "not_executed", unexecuted: "not_executed", pending: "not_executed",
201
+ };
202
+ const PLAN_STATUS = {
203
+ open: "open", new: "open", not_started: "open", todo: "open",
204
+ in_progress: "in_progress", inprogress: "in_progress", started: "in_progress", active: "in_progress",
205
+ completed: "completed", complete: "completed", done: "completed", closed: "completed", finished: "completed",
206
+ };
207
+ const statusKey = (v) => String(v).trim().toLowerCase().replace(/[\s-]+/g, "_");
208
+ function normalizeExecutionStatus(v, where) {
209
+ const s = EXECUTION_STATUS[statusKey(v)];
210
+ if (!s)
211
+ throw new Error(`${where}: execution_status "${v}" is not valid. Use one of: pass, fail, blocked, not_executed`);
212
+ return s;
213
+ }
214
+ // Unknown values pass through so the API can explain them
215
+ function normalizePlanStatus(v) {
216
+ return v === undefined || v === null || v === "" ? v : PLAN_STATUS[statusKey(v)] ?? v;
217
+ }
143
218
  function validateId(id, fieldName) {
144
219
  const str = String(id);
145
220
  if (!/^[a-zA-Z0-9_\-]+$/.test(str)) {
@@ -177,6 +252,11 @@ function parseSteps(raw) {
177
252
  throw new Error("test_steps must be a JSON array");
178
253
  return parsed.map(normalizeStep);
179
254
  }
255
+ // Step ids + text in their current order
256
+ async function currentSteps(projectId, testcaseId) {
257
+ const detail = await callApi("get", `projects/testcases/get-testcase-detail/${projectId}/${testcaseId}`);
258
+ return (detail?.data?.steps || []).map((st) => pick(st, ["id", "description"]));
259
+ }
180
260
  // Steps are added one call each; collect failures instead of aborting
181
261
  async function addSteps(projectId, testcaseId, steps) {
182
262
  let created = 0;
@@ -212,6 +292,11 @@ function buildFilters(raw, extra = {}) {
212
292
  }
213
293
  return Object.keys(merged).length ? JSON.stringify(merged) : undefined;
214
294
  }
295
+ // create_bulk: these top-level args are defaults for every item
296
+ const BULK_SHARED_FIELDS = [
297
+ "folder_id", "folderId", "priority", "status", "automated", "platform", "labels", "features", "environment",
298
+ "owner_id", "ownerId", "precondition", "custom_fields", "customFields",
299
+ ];
215
300
  // Builds the create/bulk-create payload from tool args (shared by both actions)
216
301
  async function buildTestcasePayload(projectId, tc) {
217
302
  const data = { title: tc.title ?? tc.name };
@@ -256,12 +341,9 @@ async function buildTestcasePayload(projectId, tc) {
256
341
  return data;
257
342
  }
258
343
  function jsonResponseWithHints(data, hints) {
259
- const json = JSON.stringify(data, null, 2);
260
- const hintsBlock = hints.length > 0
261
- ? `\n\n---\nHINTS:\n${hints.map((h) => `- ${h}`).join("\n")}`
262
- : "";
344
+ const hintsBlock = hints.length > 0 ? `\nHINTS: ${hints.join(" | ")}` : "";
263
345
  return {
264
- content: [{ type: "text", text: json + hintsBlock }],
346
+ content: [{ type: "text", text: JSON.stringify(compact(data)) + hintsBlock }],
265
347
  };
266
348
  }
267
349
  // ─── Error enrichment ───────────────────────────────────────────────────────
@@ -339,331 +421,235 @@ function requireArg(args, name, aliases = []) {
339
421
  return value;
340
422
  }
341
423
  // ═══════════════════════════════════════════════════════════════════════════════
424
+ // REPORT TYPE → ENDPOINT MAPPING
425
+ // ═══════════════════════════════════════════════════════════════════════════════
426
+ const REPORT_TYPE_MAP = {
427
+ // Execution
428
+ execution_summary: "execution/summary",
429
+ execution_by_cycle: "execution/by-cycle",
430
+ execution_by_tester: "execution/by-tester",
431
+ execution_by_priority: "execution/by-priority",
432
+ execution_by_environment: "execution/by-environment",
433
+ execution_by_folder: "execution/by-folder",
434
+ // Coverage
435
+ requirement_coverage: "coverage/requirement-coverage",
436
+ traceability_matrix: "coverage/traceability-matrix",
437
+ failed_requirements: "coverage/failed-requirements",
438
+ uncovered_requirements: "coverage/uncovered-requirements",
439
+ testcase_coverage: "coverage/testcase-coverage",
440
+ unlinked_testcases: "coverage/unlinked-testcases",
441
+ // Defects
442
+ defects_by_folder: "defects/by-folder",
443
+ // Team
444
+ tester_workload: "team/tester-workload",
445
+ testcase_distribution: "team/testcase-distribution",
446
+ // Trends
447
+ execution_trend: "trends/execution-trend",
448
+ execution_burnup: "trends/execution-burnup",
449
+ execution_burndown: "trends/execution-burndown",
450
+ test_creation: "trends/test-creation",
451
+ cycle_comparison: "trends/cycle-comparison",
452
+ scorecard_by_folder: "trends/scorecard-by-folder",
453
+ scorecard_by_tester: "trends/scorecard-by-tester",
454
+ created_vs_executed: "trends/created-vs-executed",
455
+ execution_by_automation: "trends/execution-by-automation",
456
+ defects_by_cycle: "trends/defects-by-cycle",
457
+ defects_by_tester: "trends/defects-by-tester",
458
+ requirement_coverage_trend: "trends/requirement-coverage-trend",
459
+ // Insights (computed from project data, no AI credits)
460
+ flaky_tests: "ai/flaky-tests",
461
+ release_readiness: "ai/release-readiness",
462
+ risk_heatmap_folder: "ai/risk-heatmap-folder",
463
+ risk_heatmap_feature: "ai/risk-heatmap-feature",
464
+ testcase_quality: "ai/testcase-quality",
465
+ predictive_failure: "ai/predictive-failure",
466
+ smart_prioritization: "ai/smart-prioritization",
467
+ tester_effectiveness: "ai/tester-effectiveness",
468
+ stale_tests: "ai/stale-tests",
469
+ defect_hotspots: "ai/defect-hotspots",
470
+ cycle_health: "ai/cycle-health",
471
+ suite_optimization: "ai/suite-optimization",
472
+ requirement_risk_matrix: "ai/requirement-risk-matrix",
473
+ execution_velocity: "ai/execution-velocity",
474
+ project_health: "ai/project-health",
475
+ };
476
+ // ═══════════════════════════════════════════════════════════════════════════════
342
477
  // TOOL DEFINITIONS (11 tools)
478
+ // Kept short on purpose: every client sends these on every turn. * = required.
343
479
  // ═══════════════════════════════════════════════════════════════════════════════
480
+ const S = { type: "string" };
481
+ const N = { type: "number" };
482
+ const VERBOSE = { type: "boolean" };
483
+ const STATUS = { type: "string", enum: ["open", "in_progress", "completed"] };
484
+ const SECTION = { type: "string", enum: ["TESTCASE", "TEST_CYCLE", "TEST_PLAN"] };
485
+ const CSV = S;
486
+ const PROJECT_VALUE = S;
344
487
  const LIST_PROJECTS_TOOL = {
345
488
  name: "list_projects",
346
- description: "List all projects the authenticated user has access to. " +
347
- "Use this first to find a projectId, then pass it to other tools.",
489
+ description: "Projects you can access. Start here to get a projectId.",
348
490
  annotations: { readOnlyHint: true },
349
- inputSchema: {
350
- type: "object",
351
- properties: {
352
- page: { type: "number", description: "Page number (default: 1)" },
353
- limit: { type: "number", description: "Items per page (default: 12)" },
354
- },
355
- required: [],
356
- },
491
+ inputSchema: { type: "object", properties: { page: N, limit: N, verbose: VERBOSE }, required: [] },
357
492
  };
358
493
  const GET_PROJECT_STRUCTURE_TOOL = {
359
494
  name: "get_project_structure",
360
- description: "Get the structure of a project: folders, labels, team members, and field options (allowed values for priority, status, etc.). " +
361
- "IMPORTANT: Call this before create/update to discover allowed field values, which are project-specific.",
495
+ description: "Folders, labels, members, field_options. Call before create/update: priority, status etc. accept only these values (any case).",
362
496
  annotations: { readOnlyHint: true },
363
497
  inputSchema: {
364
498
  type: "object",
365
499
  properties: {
366
- projectId: { type: "string", description: "Project ID (e.g. PRJ-1001)" },
367
- section: {
368
- type: "string",
369
- enum: ["TESTCASE", "TEST_CYCLE", "TEST_PLAN"],
370
- description: "Folder section (default: TESTCASE)",
371
- },
372
- include: {
373
- type: "string",
374
- description: "Comma-separated list of what to include: folders,labels,members,field_options (default: all)",
375
- },
500
+ projectId: S,
501
+ section: SECTION,
502
+ include: { type: "string", description: "CSV of folders,labels,members,field_options (default all)" },
503
+ verbose: VERBOSE,
376
504
  },
377
505
  required: ["projectId"],
378
506
  },
379
507
  };
380
508
  const SEARCH_TESTCASES_TOOL = {
381
509
  name: "search_testcases",
382
- description: "Search and list test cases in a project. Supports text search, folder filter, filters, sorting, and pagination.",
510
+ description: "Search/list test cases.",
383
511
  annotations: { readOnlyHint: true },
384
512
  inputSchema: {
385
513
  type: "object",
386
514
  properties: {
387
- projectId: { type: "string", description: "Project ID" },
388
- search: { type: "string", description: "Search text (ID, title, summary, field values)" },
389
- folder_id: {
390
- type: "number",
391
- description: "Only test cases in this folder (sub-folders included). Use 0 for test cases with no folder.",
392
- },
393
- filters: {
394
- type: "string",
395
- description: 'JSON object of filters; every value is a list. Keys are field names (use the field_options names). ' +
396
- 'Example: {"status":["Active"],"priority":["High","Critical"]}. Also supports "exclude_status".',
397
- },
398
- page: { type: "number", description: "Page number (default: 1)" },
399
- limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
400
- sortBy: { type: "string", description: "Field to sort by (e.g. created_at, title)" },
401
- sortOrder: { type: "string", enum: ["asc", "desc"], description: "Sort direction" },
515
+ projectId: S,
516
+ search: S,
517
+ folder_id: { type: "number", description: "includes sub-folders; 0 = no folder" },
518
+ filters: { type: "object", description: 'values are lists: {"status":["Active"]}; also exclude_status' },
519
+ page: N,
520
+ limit: { type: "number", description: "max 100" },
521
+ sortBy: S,
522
+ sortOrder: { type: "string", enum: ["asc", "desc"] },
523
+ verbose: VERBOSE,
402
524
  },
403
525
  required: ["projectId"],
404
526
  },
405
527
  };
406
528
  const GET_TESTCASE_TOOL = {
407
529
  name: "get_testcase",
408
- description: "Get full details of a single test case including test steps (with step ids) and attachments.",
530
+ description: "One test case + steps + attachments (view/download).",
409
531
  annotations: { readOnlyHint: true },
410
- inputSchema: {
411
- type: "object",
412
- properties: {
413
- projectId: { type: "string", description: "Project ID" },
414
- testcaseId: { type: "string", description: "Test case ID (e.g. TEST-123)" },
415
- },
416
- required: ["projectId", "testcaseId"],
417
- },
532
+ inputSchema: { type: "object", properties: { projectId: S, testcaseId: S, verbose: VERBOSE }, required: ["projectId", "testcaseId"] },
418
533
  };
419
534
  const TESTCASE_FIELDS = "title, summary, precondition, priority, status, automated, test_data, expected_result, labels, features, platform, environment, custom_fields, owner_id, folder_id";
420
535
  const MANAGE_TESTCASE_TOOL = {
421
536
  name: "manage_testcase",
422
- description: "IMPORTANT: Before creating/updating, call get_project_structure(projectId) to discover folder IDs and allowed field values.\n\n" +
423
- "Create, update or delete test cases and their test steps.\n\n" +
424
- "ACTIONS:\n" +
425
- " create - Create a single test case. Params: title (required), test_steps, and any of: " + TESTCASE_FIELDS + "\n" +
426
- " create_bulk - Create up to 100 test cases in one go. Params: testcases (JSON array; each item takes the same fields as create, including test_steps)\n" +
427
- " update - Update one field for test case(s). Params: ids, field, value (all required)\n" +
428
- " delete - PERMANENTLY delete test case(s) with their steps, history and links. Params: ids (required)\n" +
429
- " add_steps - Append steps to a test case. Params: testcaseId, test_steps\n" +
430
- " update_step - Change one step. Params: testcaseId, step_id, and description/test_data/expected_result\n" +
431
- " delete_steps - Delete steps. Params: testcaseId, step_ids (comma-separated)\n" +
432
- " reorder_steps - Set the step order. Params: testcaseId, step_ids (all step ids, in the new order)\n" +
433
- "Step ids come from get_testcase.\n\n" +
434
- "VALID UPDATE FIELDS: " + TESTCASE_FIELDS + "\n\n" +
435
- "EXAMPLES:\n" +
436
- " Create: { action: 'create', title: 'Verify login', test_steps: '[{\"description\":\"Open login page\",\"expected_result\":\"Form shown\"}]' }\n" +
437
- " Update: { action: 'update', ids: 'TEST-1', field: 'priority', value: '<value from get_project_structure>' }\n" +
438
- " Delete: { action: 'delete', ids: 'TEST-1,TEST-2' }",
537
+ description: "* = required, ids are CSV. create(title*) | create_bulk(testcases*, max 100) | update(ids*, field*, value*): one field | " +
538
+ "delete(ids*): permanent | add_steps(testcaseId*, test_steps*) | update_step(testcaseId*, step_id*) | delete_steps(testcaseId*, step_ids*) | " +
539
+ "reorder_steps(testcaseId*, step_ids*: all, new order)",
439
540
  annotations: { destructiveHint: true },
440
541
  inputSchema: {
441
542
  type: "object",
442
543
  properties: {
443
- projectId: { type: "string", description: "Project ID" },
444
- action: {
445
- type: "string",
446
- enum: ["create", "create_bulk", "update", "delete", "add_steps", "update_step", "delete_steps", "reorder_steps"],
447
- description: "Action to perform",
448
- },
449
- // create params
450
- title: { type: "string", description: "[create] Test case title" },
451
- summary: { type: "string", description: "[create] Description/summary" },
452
- priority: {
453
- type: "string",
454
- description: "[create] Priority. Values are project-specific — call get_project_structure(projectId, include='field_options') first to discover allowed values.",
455
- },
456
- folder_id: { type: "number", description: "[create] Folder ID to place the test case in" },
457
- labels: {
458
- type: "string",
459
- description: "[create] Comma-separated label names",
460
- },
461
- test_steps: {
462
- type: "string",
463
- description: '[create/add_steps] JSON array of test steps: [{"description":"...","test_data":"...","expected_result":"..."}]. Every step needs a description.',
464
- },
465
- precondition: { type: "string", description: "[create] Preconditions for the test case" },
466
- status: {
467
- type: "string",
468
- description: "[create] Status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
469
- },
470
- automated: {
471
- type: "string",
472
- description: "[create] Automation status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
473
- },
474
- test_data: { type: "string", description: "[create/update_step] Test data" },
475
- expected_result: { type: "string", description: "[create/update_step] Expected result" },
476
- platform: {
477
- type: "string",
478
- description: "[create] Platform. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
479
- },
480
- environment: { type: "string", description: "[create] Comma-separated environment values" },
481
- features: { type: "string", description: "[create] Comma-separated feature/component names" },
482
- owner_id: { type: "string", description: "[create] Owner user ID (a project member)" },
483
- custom_fields: {
484
- type: "string",
485
- description: '[create] JSON object of custom field values (e.g. {"custom1": "value", "custom2": ["val1","val2"]})',
486
- },
487
- // create_bulk params
488
- testcases: {
489
- type: "string",
490
- description: "[create_bulk] JSON array of test cases. Each item takes the create fields, e.g. " +
491
- '[{"title":"...","folder_id":12,"priority":"High","test_steps":[{"description":"...","expected_result":"..."}]}]',
492
- },
493
- // update/delete params
494
- ids: {
495
- type: "string",
496
- description: "[update/delete] Test case IDs, comma-separated (e.g. 'TEST-1' or 'TEST-1,TEST-2')",
497
- },
498
- field: {
499
- type: "string",
500
- enum: TESTCASE_FIELDS.split(", "),
501
- description: "[update] Field to update. For priority/status/automated/platform/environment, call get_project_structure(include='field_options') to get allowed values.",
502
- },
503
- value: { type: "string", description: "[update] New value. String for most fields. For labels/features/environment: comma-separated. For custom_fields: JSON object (e.g. {\"fieldName\": \"value\"})" },
504
- // step params
505
- testcaseId: { type: "string", description: "[add_steps/update_step/delete_steps/reorder_steps] Test case ID (e.g. TEST-123)" },
506
- step_id: { type: "number", description: "[update_step] Step ID to change" },
507
- description: { type: "string", description: "[update_step] Step description" },
508
- step_ids: { type: "string", description: "[delete_steps/reorder_steps] Comma-separated step IDs" },
544
+ projectId: S,
545
+ action: { type: "string", enum: ["create", "create_bulk", "update", "delete", "add_steps", "update_step", "delete_steps", "reorder_steps"] },
546
+ title: S,
547
+ summary: S,
548
+ precondition: S,
549
+ priority: PROJECT_VALUE,
550
+ status: PROJECT_VALUE,
551
+ automated: PROJECT_VALUE,
552
+ platform: PROJECT_VALUE,
553
+ folder_id: N,
554
+ labels: CSV,
555
+ features: CSV,
556
+ environment: CSV,
557
+ test_data: S,
558
+ expected_result: S,
559
+ owner_id: S,
560
+ custom_fields: { type: "object" },
561
+ test_steps: { type: "array", items: { type: "object", properties: { description: S, test_data: S, expected_result: S } } },
562
+ testcases: { type: "array", items: { type: "object" }, description: "top-level fields apply to all" },
563
+ ids: CSV,
564
+ field: { type: "string", enum: TESTCASE_FIELDS.split(", ") },
565
+ value: { type: "string", description: "CSV for list fields, JSON for custom_fields" },
566
+ testcaseId: S,
567
+ step_id: N,
568
+ description: S,
569
+ step_ids: CSV,
509
570
  },
510
571
  required: ["projectId", "action"],
511
572
  },
512
573
  };
513
574
  const MANAGE_FOLDER_TOOL = {
514
575
  name: "manage_folder",
515
- description: "Create, rename, move, or delete folders. Works across all sections (TESTCASE, TEST_CYCLE, TEST_PLAN).\n\n" +
516
- "ACTIONS:\n" +
517
- " create - Create a folder. Params: name (required), section, parentId\n" +
518
- " rename - Rename a folder. Params: folderId (required), name (required)\n" +
519
- " move - Move a folder. Params: folderId (required), parentId (omit or null for root)\n" +
520
- " delete - PERMANENTLY delete a folder, all its sub-folders AND every test case / cycle / plan inside them. " +
521
- "Only owners and project admins can do this. Confirm with the user first. Params: folderId (required)",
576
+ description: "create(name*, parentId) | rename(folderId*, name*) | move(folderId*, parentId: omit = root) | " +
577
+ "delete(folderId*): deletes its contents too; confirm with the user",
522
578
  annotations: { destructiveHint: true },
523
579
  inputSchema: {
524
580
  type: "object",
525
581
  properties: {
526
- projectId: { type: "string", description: "Project ID" },
527
- action: {
528
- type: "string",
529
- enum: ["create", "rename", "move", "delete"],
530
- description: "Action to perform",
531
- },
532
- section: {
533
- type: "string",
534
- enum: ["TESTCASE", "TEST_CYCLE", "TEST_PLAN"],
535
- description: "Folder section (default: TESTCASE)",
536
- },
537
- name: { type: "string", description: "[create/rename] Folder name (max 50 chars)" },
538
- folderId: {
539
- type: "number",
540
- description: "[rename/move/delete] ID of the folder to modify",
541
- },
542
- parentId: {
543
- type: "number",
544
- description: "[create] Parent folder ID for subfolder. [move] New parent ID (null for root).",
545
- },
582
+ projectId: S,
583
+ action: { type: "string", enum: ["create", "rename", "move", "delete"] },
584
+ section: SECTION,
585
+ name: { type: "string", description: "max 50 chars" },
586
+ folderId: N,
587
+ parentId: N,
546
588
  },
547
589
  required: ["projectId", "action"],
548
590
  },
549
591
  };
550
592
  const SEARCH_TEST_CYCLES_TOOL = {
551
593
  name: "search_test_cycles",
552
- description: "List and search test cycles in a project with execution progress.",
594
+ description: "Search/list test cycles with progress.",
553
595
  annotations: { readOnlyHint: true },
554
596
  inputSchema: {
555
597
  type: "object",
556
- properties: {
557
- projectId: { type: "string", description: "Project ID" },
558
- search: { type: "string", description: "Search by title" },
559
- folderId: { type: "number", description: "Filter by folder ID" },
560
- status: { type: "string", enum: ["open", "in_progress", "completed"], description: "Filter by status" },
561
- page: { type: "number", description: "Page number (default: 1)" },
562
- limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
563
- },
598
+ properties: { projectId: S, search: S, folderId: N, status: STATUS, page: N, limit: N, verbose: VERBOSE },
564
599
  required: ["projectId"],
565
600
  },
566
601
  };
567
602
  const MANAGE_TEST_CYCLE_TOOL = {
568
603
  name: "manage_test_cycle",
569
- description: "Manage test cycles: create, update, delete, get details, list/link/unlink/assign test cases.\n\n" +
570
- "ACTIONS:\n" +
571
- " create - Create a cycle. Params: title (required), summary, status, planned_start_date, planned_end_date, folder_id\n" +
572
- " update - Update a cycle. Params: cycleId (required), plus fields to change (title, summary, status, dates, folder_id)\n" +
573
- " delete - Delete cycle(s). Params: ids (required, comma-separated)\n" +
574
- " get_details - Get cycle details. Params: cycleId (required)\n" +
575
- " get_testcases - List test cases in cycle. Params: cycleId (required), search, page, limit\n" +
576
- " link_testcases - Link test cases to cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
577
- " unlink_testcases - Unlink test cases from cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
578
- " assign_testcases - Assign test cases to user. Params: cycleId (required), testcase_ids (required), assignee_id (required)\n\n" +
579
- "VALID STATUSES: open, in_progress, completed",
604
+ description: "create(title*, status, dates, folder_id) | update(cycleId*) | delete(ids*) | get_details(cycleId*) | get_testcases(cycleId*) | " +
605
+ "link_testcases/unlink_testcases(cycleId*, testcase_ids*) | assign_testcases(cycleId*, testcase_ids*, assignee_id*: member user_id). Dates YYYY-MM-DD.",
580
606
  annotations: { destructiveHint: true },
581
607
  inputSchema: {
582
608
  type: "object",
583
609
  properties: {
584
- projectId: { type: "string", description: "Project ID" },
610
+ projectId: S,
585
611
  action: {
586
612
  type: "string",
587
- enum: [
588
- "create",
589
- "update",
590
- "delete",
591
- "get_details",
592
- "get_testcases",
593
- "link_testcases",
594
- "unlink_testcases",
595
- "assign_testcases",
596
- ],
597
- description: "Action to perform",
598
- },
599
- // Identifiers
600
- cycleId: {
601
- type: "string",
602
- description: "Test cycle ID (e.g. TCYCLE-1). Required for update/delete/get_details/get_testcases/link/unlink/assign.",
603
- },
604
- // create/update fields
605
- title: { type: "string", description: "[create/update] Cycle title" },
606
- summary: { type: "string", description: "[create/update] Description" },
607
- status: {
608
- type: "string",
609
- enum: ["open", "in_progress", "completed"],
610
- description: "[create/update] Status",
611
- },
612
- planned_start_date: {
613
- type: "string",
614
- description: "[create/update] Start date (YYYY-MM-DD)",
615
- },
616
- planned_end_date: {
617
- type: "string",
618
- description: "[create/update] End date (YYYY-MM-DD)",
619
- },
620
- folder_id: {
621
- type: "number",
622
- description: "[create/update] Folder ID",
623
- },
624
- // delete
625
- ids: {
626
- type: "string",
627
- description: "[delete] Comma-separated cycle IDs to delete",
613
+ enum: ["create", "update", "delete", "get_details", "get_testcases", "link_testcases", "unlink_testcases", "assign_testcases"],
628
614
  },
629
- // link/unlink/assign
630
- testcase_ids: {
631
- type: "string",
632
- description: "[link_testcases/unlink_testcases/assign_testcases] Comma-separated test case IDs",
633
- },
634
- assignee_id: {
635
- type: "number",
636
- description: "[assign_testcases] User ID to assign",
637
- },
638
- // get_testcases params
639
- search: { type: "string", description: "[get_testcases] Search text" },
640
- page: { type: "number", description: "[get_testcases] Page number" },
641
- limit: { type: "number", description: "[get_testcases] Items per page" },
615
+ cycleId: S,
616
+ title: S,
617
+ summary: S,
618
+ status: STATUS,
619
+ planned_start_date: S,
620
+ planned_end_date: S,
621
+ folder_id: N,
622
+ ids: CSV,
623
+ testcase_ids: CSV,
624
+ assignee_id: N,
625
+ search: S,
626
+ page: N,
627
+ limit: N,
628
+ verbose: VERBOSE,
642
629
  },
643
630
  required: ["projectId", "action"],
644
631
  },
645
632
  };
646
633
  const EXECUTE_TESTS_TOOL = {
647
634
  name: "execute_tests",
648
- description: "Record test execution results for test cases in a test cycle. " +
649
- "Pass a single result or an array of results. " +
650
- "Valid execution statuses: pass, fail, blocked, not_executed. " +
651
- "The test cases must already be linked to the cycle (manage_test_cycle link_testcases).",
635
+ description: "Record results for test cases already linked to the cycle.",
652
636
  inputSchema: {
653
637
  type: "object",
654
638
  properties: {
655
- projectId: { type: "string", description: "Project ID" },
656
- cycleId: { type: "string", description: "Test cycle ID (e.g. TCYCLE-1)" },
639
+ projectId: S,
640
+ cycleId: S,
657
641
  results: {
658
- type: "string",
659
- description: 'JSON array of results: [{"testcase_id":"TEST-1","execution_status":"pass","actual_result":"Worked","environment":"staging"}]. ' +
660
- "Each object must have testcase_id and execution_status. actual_result and environment are optional.",
642
+ type: "array",
643
+ items: {
644
+ type: "object",
645
+ properties: { testcase_id: S, execution_status: { type: "string", enum: ["pass", "fail", "blocked", "not_executed"] }, actual_result: S },
646
+ required: ["testcase_id", "execution_status"],
647
+ },
661
648
  },
662
649
  mode: {
663
650
  type: "string",
664
651
  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.",
652
+ description: "automation = CI run: an earlier CI fail keeps it failed",
667
653
  },
668
654
  },
669
655
  required: ["projectId", "cycleId", "results"],
@@ -671,177 +657,56 @@ const EXECUTE_TESTS_TOOL = {
671
657
  };
672
658
  const MANAGE_TEST_PLAN_TOOL = {
673
659
  name: "manage_test_plan",
674
- description: "Manage test plans: create, update, delete, list, get details, link/unlink cycles, get cycles, get test cases.\n\n" +
675
- "ACTIONS:\n" +
676
- " create - Create a plan. Params: title (required), summary, status, planned_start_date, planned_end_date, folder_id\n" +
677
- " update - Update a plan. Params: planId (required), plus fields to change (title, summary, status, dates, folder_id)\n" +
678
- " delete - Delete plan(s). Params: ids (required, comma-separated)\n" +
679
- " list - List all plans. Params: search, page, limit\n" +
680
- " get_details - Get plan details. Params: planId (required)\n" +
681
- " link_cycles - Link cycles to plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
682
- " unlink_cycles - Unlink cycles from plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
683
- " get_cycles - List cycles in plan. Params: planId (required), search, page, limit\n" +
684
- " get_testcases - List test cases in plan. Params: planId (required), search, page, limit\n\n" +
685
- "VALID STATUSES: open, in_progress, completed",
660
+ description: "create(title*, status, dates, folder_id) | update(planId*) | delete(ids*) | list | get_details(planId*) | link_cycles/unlink_cycles(planId*, cycle_ids*) | " +
661
+ "get_cycles/get_testcases(planId*). Dates YYYY-MM-DD.",
686
662
  annotations: { destructiveHint: true },
687
663
  inputSchema: {
688
664
  type: "object",
689
665
  properties: {
690
- projectId: { type: "string", description: "Project ID" },
666
+ projectId: S,
691
667
  action: {
692
668
  type: "string",
693
- enum: [
694
- "create",
695
- "update",
696
- "delete",
697
- "list",
698
- "get_details",
699
- "link_cycles",
700
- "unlink_cycles",
701
- "get_cycles",
702
- "get_testcases",
703
- ],
704
- description: "Action to perform",
705
- },
706
- // Identifier
707
- planId: {
708
- type: "string",
709
- description: "Test plan ID (e.g. TPLAN-1). Required for update/delete/get_details/link_cycles/unlink_cycles/get_cycles/get_testcases.",
669
+ enum: ["create", "update", "delete", "list", "get_details", "link_cycles", "unlink_cycles", "get_cycles", "get_testcases"],
710
670
  },
711
- // create/update fields
712
- title: { type: "string", description: "[create/update] Plan title" },
713
- summary: { type: "string", description: "[create/update] Description" },
714
- status: { type: "string", enum: ["open", "in_progress", "completed"], description: "[create/update] Status" },
715
- planned_start_date: {
716
- type: "string",
717
- description: "[create/update] Start date (YYYY-MM-DD)",
718
- },
719
- planned_end_date: {
720
- type: "string",
721
- description: "[create/update] End date (YYYY-MM-DD)",
722
- },
723
- folder_id: { type: "number", description: "[create/update] Folder ID" },
724
- // delete
725
- ids: {
726
- type: "string",
727
- description: "[delete] Comma-separated plan IDs to delete",
728
- },
729
- // link/unlink cycles
730
- cycle_ids: {
731
- type: "string",
732
- description: "[link_cycles/unlink_cycles] Comma-separated test cycle IDs",
733
- },
734
- // list/get_cycles/get_testcases pagination
735
- search: { type: "string", description: "[list/get_cycles/get_testcases] Search text" },
736
- page: { type: "number", description: "[list/get_cycles/get_testcases] Page number" },
737
- limit: { type: "number", description: "[list/get_cycles/get_testcases] Items per page" },
671
+ planId: S,
672
+ title: S,
673
+ summary: S,
674
+ status: STATUS,
675
+ planned_start_date: S,
676
+ planned_end_date: S,
677
+ folder_id: N,
678
+ ids: CSV,
679
+ cycle_ids: CSV,
680
+ search: S,
681
+ page: N,
682
+ limit: N,
683
+ verbose: VERBOSE,
738
684
  },
739
685
  required: ["projectId", "action"],
740
686
  },
741
687
  };
742
688
  const GET_REPORT_TOOL = {
743
689
  name: "get_report",
744
- description: "Get reports and analytics for a project. Covers execution, coverage, trends, team, defects, and insights " +
745
- "(the insight reports are computed from your data; they do not use AI credits). " +
746
- "Available report types: execution_summary, execution_by_cycle, execution_by_tester, execution_by_priority, " +
747
- "execution_by_environment, execution_by_folder, requirement_coverage, traceability_matrix, " +
748
- "failed_requirements, uncovered_requirements, testcase_coverage, unlinked_testcases, " +
749
- "defects_by_folder, tester_workload, testcase_distribution, " +
750
- "execution_trend, execution_burnup, execution_burndown, test_creation, cycle_comparison, " +
751
- "scorecard_by_folder, scorecard_by_tester, created_vs_executed, execution_by_automation, " +
752
- "defects_by_cycle, defects_by_tester, requirement_coverage_trend, " +
753
- "flaky_tests, release_readiness, risk_heatmap_folder, risk_heatmap_feature, " +
754
- "testcase_quality, predictive_failure, smart_prioritization, tester_effectiveness, " +
755
- "stale_tests, defect_hotspots, cycle_health, suite_optimization, " +
756
- "requirement_risk_matrix, execution_velocity, project_health.",
690
+ description: "Project reports (insights use no AI credits).",
757
691
  annotations: { readOnlyHint: true },
758
692
  inputSchema: {
759
693
  type: "object",
760
694
  properties: {
761
- projectId: { type: "string", description: "Project ID" },
762
- report_type: {
763
- type: "string",
764
- description: "Report type (see description for full list)",
765
- },
766
- test_cycle_id: {
767
- type: "string",
768
- description: "Only this test cycle (one ID, e.g. TCYCLE-3)",
769
- },
770
- test_plan_id: {
771
- type: "string",
772
- description: "Only this test plan (one ID, e.g. TPLAN-2)",
773
- },
774
- folder_id: { type: "number", description: "Only this folder" },
775
- startDate: { type: "string", description: "Start date filter (YYYY-MM-DD)" },
776
- endDate: { type: "string", description: "End date filter (YYYY-MM-DD)" },
777
- granularity: {
778
- type: "string",
779
- enum: ["day", "week", "month"],
780
- description: "Granularity for trend reports",
781
- },
782
- // cycle_comparison specific
783
- cycle_ids: {
784
- type: "string",
785
- description: "[cycle_comparison] Comma-separated cycle IDs to compare",
786
- },
695
+ projectId: S,
696
+ report_type: { type: "string", enum: Object.keys(REPORT_TYPE_MAP) },
697
+ test_cycle_id: { type: "string", description: "one id" },
698
+ test_plan_id: { type: "string", description: "one id" },
699
+ folder_id: N,
700
+ startDate: { type: "string", description: "YYYY-MM-DD" },
701
+ endDate: { type: "string", description: "YYYY-MM-DD, inclusive" },
702
+ granularity: { type: "string", enum: ["day", "week", "month"] },
703
+ cycle_ids: { type: "string", description: "cycle_comparison" },
704
+ verbose: VERBOSE,
787
705
  },
788
706
  required: ["projectId", "report_type"],
789
707
  },
790
708
  };
791
709
  // ═══════════════════════════════════════════════════════════════════════════════
792
- // REPORT TYPE → ENDPOINT MAPPING
793
- // ═══════════════════════════════════════════════════════════════════════════════
794
- const REPORT_TYPE_MAP = {
795
- // Execution
796
- execution_summary: "execution/summary",
797
- execution_by_cycle: "execution/by-cycle",
798
- execution_by_tester: "execution/by-tester",
799
- execution_by_priority: "execution/by-priority",
800
- execution_by_environment: "execution/by-environment",
801
- execution_by_folder: "execution/by-folder",
802
- // Coverage
803
- requirement_coverage: "coverage/requirement-coverage",
804
- traceability_matrix: "coverage/traceability-matrix",
805
- failed_requirements: "coverage/failed-requirements",
806
- uncovered_requirements: "coverage/uncovered-requirements",
807
- testcase_coverage: "coverage/testcase-coverage",
808
- unlinked_testcases: "coverage/unlinked-testcases",
809
- // Defects
810
- defects_by_folder: "defects/by-folder",
811
- // Team
812
- tester_workload: "team/tester-workload",
813
- testcase_distribution: "team/testcase-distribution",
814
- // Trends
815
- execution_trend: "trends/execution-trend",
816
- execution_burnup: "trends/execution-burnup",
817
- execution_burndown: "trends/execution-burndown",
818
- test_creation: "trends/test-creation",
819
- cycle_comparison: "trends/cycle-comparison",
820
- scorecard_by_folder: "trends/scorecard-by-folder",
821
- scorecard_by_tester: "trends/scorecard-by-tester",
822
- created_vs_executed: "trends/created-vs-executed",
823
- execution_by_automation: "trends/execution-by-automation",
824
- defects_by_cycle: "trends/defects-by-cycle",
825
- defects_by_tester: "trends/defects-by-tester",
826
- requirement_coverage_trend: "trends/requirement-coverage-trend",
827
- // AI Insights
828
- flaky_tests: "ai/flaky-tests",
829
- release_readiness: "ai/release-readiness",
830
- risk_heatmap_folder: "ai/risk-heatmap-folder",
831
- risk_heatmap_feature: "ai/risk-heatmap-feature",
832
- testcase_quality: "ai/testcase-quality",
833
- predictive_failure: "ai/predictive-failure",
834
- smart_prioritization: "ai/smart-prioritization",
835
- tester_effectiveness: "ai/tester-effectiveness",
836
- stale_tests: "ai/stale-tests",
837
- defect_hotspots: "ai/defect-hotspots",
838
- cycle_health: "ai/cycle-health",
839
- suite_optimization: "ai/suite-optimization",
840
- requirement_risk_matrix: "ai/requirement-risk-matrix",
841
- execution_velocity: "ai/execution-velocity",
842
- project_health: "ai/project-health",
843
- };
844
- // ═══════════════════════════════════════════════════════════════════════════════
845
710
  // TOOL HANDLERS
846
711
  // ═══════════════════════════════════════════════════════════════════════════════
847
712
  // 1. list_projects
@@ -852,9 +717,7 @@ async function handleListProjects(args) {
852
717
  limit: p(args, "limit"),
853
718
  },
854
719
  });
855
- return jsonResponseWithHints(data, [
856
- "Next: call get_project_structure(projectId) to see folders, labels, and allowed field values.",
857
- ]);
720
+ return listResponse(args, data, LIST_KEYS.projects);
858
721
  }
859
722
  // 2. get_project_structure
860
723
  async function handleGetProjectStructure(args) {
@@ -891,9 +754,34 @@ async function handleGetProjectStructure(args) {
891
754
  }));
892
755
  }
893
756
  await Promise.all(promises);
894
- return jsonResponseWithHints(result, [
895
- "Use the field_options values above when creating/updating test cases.",
896
- ]);
757
+ if (p(args, "verbose"))
758
+ return jsonResponse(result, true);
759
+ const unwrap = (d) => (d && typeof d === "object" && !Array.isArray(d) && "data" in d ? d.data : d);
760
+ const shaped = {};
761
+ if (result.folders) {
762
+ const f = unwrap(result.folders);
763
+ shaped.folders = flattenFolders(Array.isArray(f) ? f : f?.folders ?? []);
764
+ }
765
+ if (result.labels) {
766
+ const l = unwrap(result.labels);
767
+ shaped.labels = (Array.isArray(l) ? l : []).map((x) => x?.labels ?? x?.name ?? x);
768
+ }
769
+ if (result.members) {
770
+ const m = unwrap(result.members);
771
+ shaped.members = (Array.isArray(m) ? m : m?.team_members ?? m?.members ?? []).map(memberRow);
772
+ }
773
+ if (result.field_options) {
774
+ shaped.field_options = {};
775
+ for (const f of Array.isArray(result.field_options) ? result.field_options : []) {
776
+ // List fields: allowed values. Custom free-text fields: their type, so the AI knows the custom_fields name.
777
+ if (Array.isArray(f?.values) && f.values.length)
778
+ shaped.field_options[f.fieldName] = f.values;
779
+ else if (f?.isSystem === false)
780
+ shaped.field_options[f.fieldName] = f.fieldType;
781
+ }
782
+ }
783
+ // Not compacted: an empty section must read as "none" (folders: []), not vanish
784
+ return { content: [{ type: "text", text: JSON.stringify(shaped) }] };
897
785
  }
898
786
  // 3. search_testcases
899
787
  async function handleSearchTestcases(args) {
@@ -925,7 +813,22 @@ async function handleSearchTestcases(args) {
925
813
  tc.url = `${baseUrl}/${projectId}/testcases/${tc.id}`;
926
814
  });
927
815
  }
928
- return jsonResponse(data);
816
+ return listResponse(args, data, LIST_KEYS.testcases, "id");
817
+ }
818
+ // Keep the useful, shareable fields; drop internal ones (s3_path, uploaded_by, status)
819
+ function shapeAttachments(list) {
820
+ return (Array.isArray(list) ? list : []).map((a) => {
821
+ const s3 = typeof a?.s3_path === "string" ? a.s3_path : "";
822
+ const filename = (s3 ? s3.split("/").pop() : undefined) || a?.filename || a?.name;
823
+ return {
824
+ attachment_id: a?.attachment_id ?? a?.id,
825
+ filename,
826
+ size: a?.size,
827
+ uploaded_at: a?.uploaded_at,
828
+ viewUrl: a?.viewUrl,
829
+ downloadUrl: a?.downloadUrl,
830
+ };
831
+ });
929
832
  }
930
833
  // 4. get_testcase
931
834
  async function handleGetTestcase(args) {
@@ -940,8 +843,22 @@ async function handleGetTestcase(args) {
940
843
  const data = await callApi("get", `projects/testcases/get-testcase-detail/${projectId}/${testcaseId}`);
941
844
  if (data?.data?.testcase) {
942
845
  data.data.testcase.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${testcaseId}`;
846
+ // Attachments live on a separate endpoint; fold them in so the agent gets shareable
847
+ // presigned links (view/download) without the test-case read ever failing on their account.
848
+ try {
849
+ const orgId = await getOrganizationId();
850
+ const att = await callApi("get", `attachments/testcase/${orgId}/${projectId}`, {
851
+ query: { target_id: testcaseId },
852
+ });
853
+ data.data.testcase.attachments = shapeAttachments(att?.attachments ?? att?.data?.attachments);
854
+ }
855
+ catch {
856
+ data.data.testcase.attachments = [];
857
+ data.data.testcase.attachments_note =
858
+ "Could not load attachments for this test case (the case itself loaded fine). Re-run get_testcase to retry.";
859
+ }
943
860
  }
944
- return jsonResponse(data);
861
+ return jsonResponse(data, !!p(args, "verbose"));
945
862
  }
946
863
  // 5. manage_testcase
947
864
  async function handleManageTestcase(args) {
@@ -963,9 +880,7 @@ async function handleManageTestcase(args) {
963
880
  const testcaseData = await buildTestcasePayload(projectId, { ...args, title });
964
881
  const createResult = await callApi("post", `projects/testcases/create-testcase/${projectId}`, { body: testcaseData });
965
882
  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
- ];
883
+ const hints = [];
969
884
  if (tcId) {
970
885
  createResult.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${tcId}`;
971
886
  if (testSteps?.length) {
@@ -987,9 +902,14 @@ async function handleManageTestcase(args) {
987
902
  if (!Array.isArray(testcases) || testcases.length === 0) {
988
903
  throw new Error("testcases must be a non-empty JSON array");
989
904
  }
905
+ const shared = {};
906
+ for (const f of BULK_SHARED_FIELDS)
907
+ if (args[f] !== undefined && args[f] !== null && args[f] !== "")
908
+ shared[f] = args[f];
990
909
  // Validate everything before creating anything
991
910
  const items = [];
992
- for (const [i, tc] of testcases.entries()) {
911
+ for (const [i, item] of testcases.entries()) {
912
+ const tc = item && typeof item === "object" ? { ...shared, ...item } : item;
993
913
  if (!tc?.title && !tc?.name)
994
914
  throw new Error(`testcases[${i}]: title is required`);
995
915
  try {
@@ -1114,7 +1034,7 @@ async function handleManageTestcase(args) {
1114
1034
  query: { projectId },
1115
1035
  body: { stepIds },
1116
1036
  });
1117
- return jsonResponse(data);
1037
+ return jsonResponse({ ...compact(data), steps: await currentSteps(projectId, tcId) });
1118
1038
  }
1119
1039
  case "reorder_steps": {
1120
1040
  const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
@@ -1124,7 +1044,7 @@ async function handleManageTestcase(args) {
1124
1044
  const data = await callApi("put", `projects/testcases/teststeps/${projectId}/${tcId}/reorder`, {
1125
1045
  body: { stepIds },
1126
1046
  });
1127
- return jsonResponse(data);
1047
+ return jsonResponse({ ...compact(data), steps: await currentSteps(projectId, tcId) });
1128
1048
  }
1129
1049
  default:
1130
1050
  throw new Error(`Unknown action: ${action}. Use: create, create_bulk, update, delete, add_steps, update_step, delete_steps, reorder_steps`);
@@ -1209,7 +1129,7 @@ async function handleSearchTestCycles(args) {
1209
1129
  validateId(projectId, "projectId");
1210
1130
  const folderId = p(args, "folderId", "folder_id");
1211
1131
  const filters = buildFilters(undefined, {
1212
- status: p(args, "status"),
1132
+ status: normalizePlanStatus(p(args, "status")),
1213
1133
  folder_id: folderId === undefined || folderId === null || folderId === "" ? undefined : Number(folderId),
1214
1134
  });
1215
1135
  const data = await callApi("get", `projects/test-cycles/get-test-cycles/${projectId}`, {
@@ -1228,7 +1148,7 @@ async function handleSearchTestCycles(args) {
1228
1148
  cycle.url = `${baseUrl}/${projectId}/testcycles/${id}`;
1229
1149
  });
1230
1150
  }
1231
- return jsonResponse(data);
1151
+ return listResponse(args, data, LIST_KEYS.cycles, "test_cycle_id");
1232
1152
  }
1233
1153
  // 8. manage_test_cycle
1234
1154
  async function handleManageTestCycle(args) {
@@ -1248,7 +1168,7 @@ async function handleManageTestCycle(args) {
1248
1168
  if (p(args, "summary"))
1249
1169
  body.summary = p(args, "summary");
1250
1170
  if (p(args, "status"))
1251
- body.status = p(args, "status");
1171
+ body.status = normalizePlanStatus(p(args, "status"));
1252
1172
  if (p(args, "planned_start_date", "plannedStartDate"))
1253
1173
  body.planned_start_date = p(args, "planned_start_date", "plannedStartDate");
1254
1174
  if (p(args, "planned_end_date", "plannedEndDate"))
@@ -1268,7 +1188,7 @@ async function handleManageTestCycle(args) {
1268
1188
  if (p(args, "summary"))
1269
1189
  body.summary = p(args, "summary");
1270
1190
  if (p(args, "status"))
1271
- body.status = p(args, "status");
1191
+ body.status = normalizePlanStatus(p(args, "status"));
1272
1192
  if (p(args, "planned_start_date", "plannedStartDate"))
1273
1193
  body.planned_start_date = p(args, "planned_start_date", "plannedStartDate");
1274
1194
  if (p(args, "planned_end_date", "plannedEndDate"))
@@ -1295,7 +1215,7 @@ async function handleManageTestCycle(args) {
1295
1215
  if (data?.data) {
1296
1216
  data.data.url = `${getFrontendBaseUrl()}/${projectId}/testcycles/${cycleId}`;
1297
1217
  }
1298
- return jsonResponse(data);
1218
+ return jsonResponse(data, !!p(args, "verbose"));
1299
1219
  }
1300
1220
  case "get_testcases": {
1301
1221
  const cycleId = p(args, "cycleId", "cycle_id", "id");
@@ -1309,7 +1229,7 @@ async function handleManageTestCycle(args) {
1309
1229
  limit: p(args, "limit"),
1310
1230
  },
1311
1231
  });
1312
- return jsonResponse(data);
1232
+ return listResponse(args, data, LIST_KEYS.linkedCases);
1313
1233
  }
1314
1234
  case "link_testcases": {
1315
1235
  const cycleId = p(args, "cycleId", "cycle_id", "id");
@@ -1367,7 +1287,8 @@ async function handleExecuteTests(args) {
1367
1287
  throw new Error("results is required");
1368
1288
  validateId(projectId, "projectId");
1369
1289
  validateId(cycleId, "cycleId");
1370
- const results = safeJsonParse(resultsRaw, "results");
1290
+ const parsedResults = safeJsonParse(resultsRaw, "results");
1291
+ const results = parsedResults && !Array.isArray(parsedResults) && typeof parsedResults === "object" ? [parsedResults] : parsedResults;
1371
1292
  if (!Array.isArray(results) || results.length === 0) {
1372
1293
  throw new Error("results must be a non-empty array");
1373
1294
  }
@@ -1386,7 +1307,7 @@ async function handleExecuteTests(args) {
1386
1307
  }
1387
1308
  const out = {
1388
1309
  testcase_id: validateId(testcaseId, "testcase_id"),
1389
- execution_status: status,
1310
+ execution_status: normalizeExecutionStatus(status, `results[${i}]`),
1390
1311
  };
1391
1312
  const actual = r.actual_result || r.actualResult;
1392
1313
  if (actual)
@@ -1438,7 +1359,7 @@ async function handleManageTestPlan(args) {
1438
1359
  if (p(args, "summary"))
1439
1360
  body.summary = p(args, "summary");
1440
1361
  if (p(args, "status"))
1441
- body.status = p(args, "status");
1362
+ body.status = normalizePlanStatus(p(args, "status"));
1442
1363
  if (p(args, "planned_start_date", "plannedStartDate"))
1443
1364
  body.planned_start_date = p(args, "planned_start_date", "plannedStartDate");
1444
1365
  if (p(args, "planned_end_date", "plannedEndDate"))
@@ -1458,7 +1379,7 @@ async function handleManageTestPlan(args) {
1458
1379
  if (p(args, "summary"))
1459
1380
  body.summary = p(args, "summary");
1460
1381
  if (p(args, "status"))
1461
- body.status = p(args, "status");
1382
+ body.status = normalizePlanStatus(p(args, "status"));
1462
1383
  if (p(args, "planned_start_date", "plannedStartDate"))
1463
1384
  body.planned_start_date = p(args, "planned_start_date", "plannedStartDate");
1464
1385
  if (p(args, "planned_end_date", "plannedEndDate"))
@@ -1492,7 +1413,7 @@ async function handleManageTestPlan(args) {
1492
1413
  plan.url = `${baseUrl}/${projectId}/testplans/${id}`;
1493
1414
  });
1494
1415
  }
1495
- return jsonResponse(data);
1416
+ return listResponse(args, data, LIST_KEYS.plans, "test_plan_id");
1496
1417
  }
1497
1418
  case "get_details": {
1498
1419
  const planId = p(args, "planId", "plan_id", "id");
@@ -1503,7 +1424,7 @@ async function handleManageTestPlan(args) {
1503
1424
  if (data?.data) {
1504
1425
  data.data.url = `${getFrontendBaseUrl()}/${projectId}/testplans/${planId}`;
1505
1426
  }
1506
- return jsonResponse(data);
1427
+ return jsonResponse(data, !!p(args, "verbose"));
1507
1428
  }
1508
1429
  case "link_cycles": {
1509
1430
  const planId = p(args, "planId", "plan_id", "id");
@@ -1541,7 +1462,7 @@ async function handleManageTestPlan(args) {
1541
1462
  limit: p(args, "limit"),
1542
1463
  },
1543
1464
  });
1544
- return jsonResponse(data);
1465
+ return listResponse(args, data, LIST_KEYS.cycles);
1545
1466
  }
1546
1467
  case "get_testcases": {
1547
1468
  const planId = p(args, "planId", "plan_id", "id");
@@ -1555,7 +1476,7 @@ async function handleManageTestPlan(args) {
1555
1476
  limit: p(args, "limit"),
1556
1477
  },
1557
1478
  });
1558
- return jsonResponse(data);
1479
+ return listResponse(args, data, LIST_KEYS.linkedCases);
1559
1480
  }
1560
1481
  default:
1561
1482
  throw new Error(`Unknown action: ${action}. Use: create, update, delete, list, get_details, link_cycles, unlink_cycles, get_cycles, get_testcases`);
@@ -1603,7 +1524,7 @@ async function handleGetReport(args) {
1603
1524
  const data = await callApi("get", `projects/reports/${endpointPath}/${projectId}`, {
1604
1525
  query,
1605
1526
  });
1606
- return jsonResponse(data);
1527
+ return jsonResponse(data, !!p(args, "verbose"));
1607
1528
  }
1608
1529
  // ═══════════════════════════════════════════════════════════════════════════════
1609
1530
  // SERVER SETUP