common-qa-mcp 0.5.0 → 0.7.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.
Files changed (69) hide show
  1. package/README.md +37 -79
  2. package/dist/adapters/remoteMcp/adapter.d.ts +7 -1
  3. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  4. package/dist/adapters/remoteMcp/adapter.js +8 -3
  5. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  6. package/dist/adapters/simplifyqa/adapter.d.ts +112 -26
  7. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  8. package/dist/adapters/simplifyqa/adapter.js +731 -240
  9. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  10. package/dist/adapters/simplifyqa/mapper.d.ts +5 -1
  11. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  12. package/dist/adapters/simplifyqa/mapper.js +45 -1
  13. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  14. package/dist/adapters/types.d.ts +55 -13
  15. package/dist/adapters/types.d.ts.map +1 -1
  16. package/dist/adapters/walnut/actionTypes.d.ts +1 -1
  17. package/dist/adapters/walnut/actionTypes.d.ts.map +1 -1
  18. package/dist/adapters/walnut/actionTypes.js +37 -0
  19. package/dist/adapters/walnut/actionTypes.js.map +1 -1
  20. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  21. package/dist/adapters/walnut/mapper.js +12 -1
  22. package/dist/adapters/walnut/mapper.js.map +1 -1
  23. package/dist/domain/types.d.ts +105 -15
  24. package/dist/domain/types.d.ts.map +1 -1
  25. package/dist/initAgents.d.ts.map +1 -1
  26. package/dist/initAgents.js +132 -26
  27. package/dist/initAgents.js.map +1 -1
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +95 -1
  30. package/dist/server.js.map +1 -1
  31. package/dist/tools/backlog.tools.d.ts.map +1 -1
  32. package/dist/tools/backlog.tools.js +24 -2
  33. package/dist/tools/backlog.tools.js.map +1 -1
  34. package/dist/tools/connection.tools.d.ts.map +1 -1
  35. package/dist/tools/connection.tools.js +15 -3
  36. package/dist/tools/connection.tools.js.map +1 -1
  37. package/dist/tools/defects.tools.d.ts.map +1 -1
  38. package/dist/tools/defects.tools.js +29 -3
  39. package/dist/tools/defects.tools.js.map +1 -1
  40. package/dist/tools/discovery.tools.d.ts.map +1 -1
  41. package/dist/tools/discovery.tools.js +304 -137
  42. package/dist/tools/discovery.tools.js.map +1 -1
  43. package/dist/tools/gapAnalysis.tools.d.ts +31 -0
  44. package/dist/tools/gapAnalysis.tools.d.ts.map +1 -0
  45. package/dist/tools/gapAnalysis.tools.js +287 -0
  46. package/dist/tools/gapAnalysis.tools.js.map +1 -0
  47. package/dist/tools/impactAnalysis.tools.d.ts +17 -0
  48. package/dist/tools/impactAnalysis.tools.d.ts.map +1 -0
  49. package/dist/tools/impactAnalysis.tools.js +352 -0
  50. package/dist/tools/impactAnalysis.tools.js.map +1 -0
  51. package/dist/tools/markdown.d.ts.map +1 -1
  52. package/dist/tools/markdown.js +9 -5
  53. package/dist/tools/markdown.js.map +1 -1
  54. package/dist/tools/objects.tools.d.ts.map +1 -1
  55. package/dist/tools/objects.tools.js +174 -18
  56. package/dist/tools/objects.tools.js.map +1 -1
  57. package/dist/tools/recording.tools.d.ts.map +1 -1
  58. package/dist/tools/recording.tools.js +108 -10
  59. package/dist/tools/recording.tools.js.map +1 -1
  60. package/dist/tools/run.tools.d.ts.map +1 -1
  61. package/dist/tools/run.tools.js +257 -63
  62. package/dist/tools/run.tools.js.map +1 -1
  63. package/dist/tools/tasks.tools.d.ts.map +1 -1
  64. package/dist/tools/tasks.tools.js +29 -3
  65. package/dist/tools/tasks.tools.js.map +1 -1
  66. package/dist/tools/testcase.tools.d.ts.map +1 -1
  67. package/dist/tools/testcase.tools.js +14 -5
  68. package/dist/tools/testcase.tools.js.map +1 -1
  69. package/package.json +1 -1
@@ -17,6 +17,30 @@ function totalOf(rows) {
17
17
  const t = rows.totalCount;
18
18
  return typeof t === 'number' ? t : undefined;
19
19
  }
20
+ /** Render a `ModuleSaved` (single node or subtree) as human-readable text so the model sees the
21
+ * full tree shape it just created — id, code, isRoot flag, parent, and nested children indented.
22
+ * `failedAt` (if set on root) is surfaced at the bottom so a partial-success is never hidden. */
23
+ function formatModuleSaved(saved, indent = 0) {
24
+ const pad = ' '.repeat(indent);
25
+ const code = saved.code ? ` [${saved.code}]` : '';
26
+ const parent = saved.parentId ? `, parent=${saved.parentId}` : '';
27
+ const root = saved.isRoot ? ', isRoot=true' : '';
28
+ const head = `${pad}- ${saved.name}${code} (id ${saved.id}${root}${parent})`;
29
+ const childLines = (saved.children ?? []).map((c) => formatModuleSaved(c, indent + 1));
30
+ const parts = [head, ...childLines];
31
+ if (indent === 0 && saved.failedAt) {
32
+ parts.push(`\n⚠ Subtree create stopped at "${saved.failedAt.path}": ${saved.failedAt.error}`);
33
+ parts.push(' Nodes listed above ARE persisted. Fix the error, then create the remaining nodes.');
34
+ }
35
+ if (indent === 0) {
36
+ const created = countCreated(saved);
37
+ parts.unshift(`Created ${created} module${created === 1 ? '' : 's'}:`);
38
+ }
39
+ return parts.join('\n');
40
+ }
41
+ function countCreated(saved) {
42
+ return 1 + (saved.children ?? []).reduce((n, c) => n + countCreated(c), 0);
43
+ }
20
44
  /** Header line for a list tool: "Showing 100 of 350 test cases" when the backend returned a
21
45
  * totalCount larger than the page, else "N test cases". Pluralises via `plural` (defaults to `s`
22
46
  * suffix — pass explicit forms for "stor y/ies", etc). */
@@ -88,7 +112,9 @@ export function registerDiscoveryTools(server, deps) {
88
112
  'use `list_functions` instead. Trigger words for THIS tool: "test case", "TC", "TC_", ' +
89
113
  '"scenario", "script". Requires a live connection (qa_connect). Returns a summary list — id, ' +
90
114
  'title, technology, execution type, status, step count. Use `tc_get` to read one in full. ' +
91
- 'Read-only, product-neutral.',
115
+ 'Read-only, product-neutral. When presenting this as a table, label the columns "ID" and ' +
116
+ '"Name" — the underlying fields are literally named `code`/`title`, but that\'s internal ' +
117
+ 'wire terminology, not what to show the user.',
92
118
  inputSchema: {
93
119
  search: z.string().optional().describe('Free-text search over title / id.'),
94
120
  status: z.string().optional().describe('Filter by status.'),
@@ -126,7 +152,7 @@ export function registerDiscoveryTools(server, deps) {
126
152
  const obsoleteCount = rows.filter((r) => r.obsolete === true).length;
127
153
  const obsoleteNote = obsoleteCount ? ` · ${obsoleteCount} obsolete` : '';
128
154
  const text = `${countHeader(rows, 'test case')}${obsoleteNote}:\n\n` +
129
- '| key | title | technology | execution | status | steps | obsolete |\n|---|---|---|---|---|---|---|\n' +
155
+ '| ID | Name | technology | execution | status | steps | obsolete |\n|---|---|---|---|---|---|---|\n' +
130
156
  rows
131
157
  .map((r) => `| ${escCell(r.code)} | ${escCell(r.title)} | ${r.technologyType ?? '-'} | ${r.executionType ?? '-'} | ${r.status ?? '-'} | ${r.stepCount ?? '-'} | ${r.obsolete === true ? 'yes' : ''} |`)
132
158
  .join('\n');
@@ -232,69 +258,55 @@ export function registerDiscoveryTools(server, deps) {
232
258
  }
233
259
  });
234
260
  server.registerTool('tc_clone', {
235
- title: 'Clone a test case (copy in place with steps, versions, testdata)',
261
+ title: 'Save a test case as a new test case (same project + module)',
236
262
  description: 'FOR TEST CASES ONLY. NOT for reusable functions: if the user names an `FN_*` key or asks to ' +
237
263
  'clone a function, use `fn_clone` instead. Trigger words for THIS tool: "test case", "TC", ' +
238
264
  '"TC_", "scenario", "script".\n\n' +
239
- 'Save an existing test case as a NEW copy in the SAME project + module — all steps, ' +
240
- 'versions, test data (all environments), parameters, object refs, and nested function ' +
241
- "calls are carried over. Uses SimplifyQA's native TC/FN copy endpoint. `tcId` accepts the " +
242
- "product's id or business key (`TC_*`).\n\n" +
243
- 'Naming: pass `newName` for a custom title; otherwise the backend auto-names the copy as ' +
244
- '`{sourceName}_copy1`, `_copy2`, etc.\n\n' +
245
- 'VERSIONS — TWO-STEP: call once WITHOUT `versions` to get a preview of the source\'s ' +
246
- 'versions and confirm with the user which to clone; then call again with `versions` set. ' +
247
- '`versions:["*"]` clones every version; `versions:["v1.0","v2.0",...]` clones only those. ' +
248
- 'The tool REFUSES to clone until `versions` is passed — the preview step is not skippable.\n\n' +
249
- 'PERMISSION: requires the `copy_testcase_across_project` privilege on the project (admins ' +
250
- "have it by default; other roles need it granted). If you get a permission error, ask your " +
251
- 'project admin. Requires a live connection.',
265
+ 'Save an existing test case as a NEW test case (new TC id) in the SAME project + module. ' +
266
+ 'Mirrors the FE in-editor "Save as new TC" dialog (operation: SAVEASNEWTC). Clones ONE ' +
267
+ 'source version — pass `sourceVersionId` to pick which; defaults to the TC\'s currently ' +
268
+ 'selected version. `tcId` accepts the numeric id or business key (`TC_*`).\n\n' +
269
+ '`newName` is REQUIRED — backend rejects an empty name on SAVEASNEWTC.\n\n' +
270
+ 'TESTDATA — TWO-STEP: call once WITHOUT `tdCopy` to get the "Copy Test Data" prompt and ' +
271
+ 'confirm with the user; then call again with `tdCopy` set. Mirrors the FE\'s "COPY TEST ' +
272
+ 'DATA" modal (Cancel = false, Confirm = true). The tool REFUSES to clone until `tdCopy` ' +
273
+ 'is passed — the prompt step is not skippable.\n\n' +
274
+ '`tdCopy:true` clones the source version\'s testdata iterations across every environment. ' +
275
+ '`tdCopy:false` copies only the column STRUCTURE — one empty iteration (`ITR-1`) per ' +
276
+ 'environment; cells fall back to each parameter\'s project-level `defaultValue` if any.\n\n' +
277
+ 'Requires a live connection.',
252
278
  inputSchema: {
253
279
  tcId: z.string().describe('The source test case id or business key (e.g. TC_123).'),
254
- newName: z.string().optional().describe('Custom title for the copy. Omit to let the backend auto-name it (`{sourceName}_copy1`).'),
255
- versions: z.array(z.string()).optional().describe('REQUIRED to actually clone. Version NAMES to clone (e.g. ["v1.0"]), or ["*"] to clone every version. Omit on the first call to get a preview + version list; ask the user which they want; then call again with the chosen value.'),
280
+ newName: z.string().min(1).describe('Name for the new test case (required by backend).'),
281
+ tdCopy: z.boolean().optional().describe('REQUIRED to actually clone. Whether to clone the source version\'s testdata iterations. Omit on the first call to get the confirmation prompt; ask the user, then call again with the chosen value.'),
282
+ sourceVersionId: z.string().optional().describe('Which version of the source TC to clone; defaults to the TC\'s current version.'),
256
283
  },
257
284
  annotations: { readOnlyHint: false },
258
- }, async ({ tcId, newName, versions }) => {
285
+ }, async ({ tcId, newName, tdCopy, sourceVersionId }) => {
259
286
  const adapter = deps.getAdapter();
260
287
  if (!adapter)
261
288
  return err(NOT_CONNECTED);
262
289
  if (!adapter.cloneTestCase)
263
- return err('This product does not support test-case clone.');
264
- if (!adapter.getTestCase)
265
- return err('This product does not support test-case read.');
266
- // Preview step: no `versions` → fetch the source's version list and hand it back so the
267
- // caller can confirm with the user which to clone. Adapter is not touched for the write.
268
- if (versions === undefined) {
269
- try {
270
- const tc = await adapter.getTestCase(tcId);
271
- const names = (tc.versions ?? []).map((v) => v.name);
272
- if (names.length === 0) {
273
- return err(`Cannot clone ${tcId}: source has no versions to copy.`);
274
- }
275
- return ok(`**${tcId}** has ${names.length} version${names.length === 1 ? '' : 's'}: ` +
276
- `${names.map((n) => `\`${n}\``).join(', ')}.\n\n` +
277
- `**Ask the user which version(s) to clone**, then call \`tc_clone\` again:\n` +
278
- `- \`versions: ["*"]\` → clone ALL versions\n` +
279
- `- \`versions: ${JSON.stringify([names[0]])}\` → clone only that version\n` +
280
- `- \`versions: [name1, name2]\` → clone a specific subset`, { preview: true, versions: names, totalCount: names.length });
281
- }
282
- catch (e) {
283
- return err(describeError(e));
284
- }
290
+ return err('This product does not support save-as-new-testcase.');
291
+ // Prompt step: no `tdCopy` → hand back the "Copy Test Data" question.
292
+ if (tdCopy === undefined) {
293
+ return ok(`**COPY TEST DATA** — Do you want to copy test data to new test case **${newName}** (from ${tcId})?\n\n` +
294
+ `If you do not want to copy the test data, only the column structure will be carried over (one empty ITR-1 per environment).\n\n` +
295
+ `**Ask the user**, then call \`tc_clone\` again:\n` +
296
+ `- \`tdCopy: true\` → copy iterations across all environments (Confirm)\n` +
297
+ `- \`tdCopy: false\` → structure only, empty values (Cancel)`, { preview: true, needs: 'tdCopy' });
285
298
  }
286
- // Explicit sentinel ["*"] = "all versions" — pass through as undefined to the adapter.
287
- const isAll = versions.length === 1 && versions[0] === '*';
288
- const effectiveVersions = isAll ? undefined : versions;
289
299
  try {
290
- const ref = await adapter.cloneTestCase(tcId, newName, effectiveVersions);
291
- const nameNote = newName ? ` renamed to **${newName}**` : ' (auto-named by the backend)';
292
- const versionNote = isAll
293
- ? ' All versions were carried over.'
294
- : ` Only version(s) ${versions.map((v) => `**${v}**`).join(', ')} were carried over.`;
295
- return ok(`Cloned ${tcId} →${nameNote}. New id: **${ref.id}**` +
300
+ const ref = await adapter.cloneTestCase(tcId, newName, {
301
+ tdCopy,
302
+ ...(sourceVersionId !== undefined ? { sourceVersionId } : {}),
303
+ });
304
+ const tdNote = tdCopy
305
+ ? ' Testdata iterations were copied across environments.'
306
+ : ' Testdata structure was carried over but values are empty (tdCopy:false).';
307
+ return ok(`Cloned ${tcId} → **${newName}**. New id: **${ref.id}**` +
296
308
  (ref.productUrl ? ` · ${ref.productUrl}` : '') +
297
- `.${versionNote}`, { savedRef: ref });
309
+ `.${tdNote}`, { savedRef: ref });
298
310
  }
299
311
  catch (e) {
300
312
  return err(describeError(e));
@@ -309,16 +321,19 @@ export function registerDiscoveryTools(server, deps) {
309
321
  'new version row with cloned steps. Mirrors the SimplifyQA UI "Save as new version" dialog ' +
310
322
  '(operation: SAVENEWVERSION). `tcId` accepts the numeric id or business key (`TC_*`).\n\n' +
311
323
  '`versionName` must be unique for this TC — the backend rejects duplicates.\n\n' +
312
- '`tdCopy` (default TRUE) clones the source version\'s testdata iterations across every ' +
313
- 'environment. Pass `false` to copy only the iteration STRUCTURE — values are empty, and ' +
314
- 'parameters without a project-level defaultValue get suffixed column names. That matches ' +
315
- 'the FE\'s "Cancel" button on the testdata-copy prompt.\n\n' +
324
+ 'TESTDATA — TWO-STEP: call once WITHOUT `tdCopy` to get the "Copy Test Data" prompt and ' +
325
+ 'confirm with the user; then call again with `tdCopy` set. Mirrors the FE\'s "COPY TEST ' +
326
+ 'DATA" modal (Cancel = false, Confirm = true). The tool REFUSES to snapshot until `tdCopy` ' +
327
+ 'is passed — the prompt step is not skippable.\n\n' +
328
+ '`tdCopy:true` clones the source version\'s testdata iterations across every environment. ' +
329
+ '`tdCopy:false` copies only the column STRUCTURE — one empty iteration (`ITR-1`) per ' +
330
+ 'environment; cells fall back to each parameter\'s project-level `defaultValue` if any.\n\n' +
316
331
  '`sourceVersionId` picks which version to snapshot; omit to snapshot the TC\'s currently ' +
317
332
  'selected version. Requires a live connection.',
318
333
  inputSchema: {
319
334
  tcId: z.string().describe('The test case id or business key (e.g. TC_123).'),
320
335
  versionName: z.string().min(1).describe('Name for the new version (must be unique for this TC).'),
321
- tdCopy: z.boolean().optional().describe('Clone testdata iterations from the source version. Default: true.'),
336
+ tdCopy: z.boolean().optional().describe('REQUIRED to actually snapshot. Clone testdata iterations from the source version. Omit on the first call to get the confirmation prompt; ask the user, then call again with the chosen value.'),
322
337
  sourceVersionId: z.string().optional().describe('Version to snapshot; defaults to the TC\'s current version.'),
323
338
  },
324
339
  annotations: { readOnlyHint: false },
@@ -328,14 +343,22 @@ export function registerDiscoveryTools(server, deps) {
328
343
  return err(NOT_CONNECTED);
329
344
  if (!adapter.saveAsNewVersion)
330
345
  return err('This product does not support save-as-new-version.');
331
- const effectiveTdCopy = tdCopy ?? true;
346
+ // Prompt step: no `tdCopy` → hand back the "Copy Test Data" question so the caller can
347
+ // confirm with the user before writing. Mirrors the FE's COPY TEST DATA modal.
348
+ if (tdCopy === undefined) {
349
+ return ok(`**COPY TEST DATA** — Do you want to copy test data to new version **${versionName}** of ${tcId}?\n\n` +
350
+ `If you do not want to copy the test data, only the column structure will be carried over (one empty ITR-1 per environment).\n\n` +
351
+ `**Ask the user**, then call \`tc_save_as_new_version\` again:\n` +
352
+ `- \`tdCopy: true\` → copy iterations across all environments (Confirm)\n` +
353
+ `- \`tdCopy: false\` → structure only, empty values (Cancel)`, { preview: true, needs: 'tdCopy' });
354
+ }
332
355
  try {
333
356
  const ref = await adapter.saveAsNewVersion(tcId, {
334
357
  versionName,
335
- tdCopy: effectiveTdCopy,
358
+ tdCopy,
336
359
  ...(sourceVersionId !== undefined ? { sourceVersionId } : {}),
337
360
  });
338
- const tdNote = effectiveTdCopy
361
+ const tdNote = tdCopy
339
362
  ? ' Testdata iterations were copied across environments.'
340
363
  : ' Testdata structure was carried over but values are empty (tdCopy:false).';
341
364
  return ok(`Snapshotted ${tcId} as new version **${versionName}**` +
@@ -746,69 +769,55 @@ export function registerDiscoveryTools(server, deps) {
746
769
  }
747
770
  });
748
771
  server.registerTool('fn_clone', {
749
- title: 'Clone a reusable function (copy in place with steps, versions, testdata)',
772
+ title: 'Save a reusable function as a new function (same project + module)',
750
773
  description: 'FOR REUSABLE FUNCTIONS ONLY. NOT for test cases: if the user names a `TC_*` key or asks to ' +
751
774
  'clone a test case / scenario / script, use `tc_clone` instead. Trigger words for THIS tool: ' +
752
775
  '"function", "reusable function", "FN_", "FN-".\n\n' +
753
- 'Save an existing reusable FUNCTION as a NEW copy in the SAME project + module — all steps, ' +
754
- 'versions, test data (all environments), parameters, object refs, and nested function calls ' +
755
- "are carried over. Uses SimplifyQA's native TC/FN copy endpoint with `type:'FN'`. `fnId` " +
756
- "accepts the product's id or business key (`FN_*`).\n\n" +
757
- 'Naming: pass `newName` for a custom title; otherwise the backend auto-names the copy as ' +
758
- '`{sourceName}_copy1`, `_copy2`, etc.\n\n' +
759
- 'VERSIONS — TWO-STEP: call once WITHOUT `versions` to get a preview of the source\'s ' +
760
- 'versions and confirm with the user which to clone; then call again with `versions` set. ' +
761
- '`versions:["*"]` clones every version; `versions:["v1.0","v2.0",...]` clones only those. ' +
762
- 'The tool REFUSES to clone until `versions` is passed — the preview step is not skippable.\n\n' +
763
- 'PERMISSION: requires the `copy_function_across_projects` privilege on the project (admins ' +
764
- "have it by default; other roles need it granted). If you get a permission error, ask your " +
765
- 'project admin. Requires a live connection.',
776
+ 'Save an existing reusable FUNCTION as a NEW function (new FN id) in the SAME project + ' +
777
+ 'module. Mirrors the FE in-editor "Save as new function" dialog (operation: SAVEASNEWFN). ' +
778
+ 'Clones ONE source version — pass `sourceVersionId` to pick which; defaults to the FN\'s ' +
779
+ 'newest version. `fnId` accepts the numeric id or business key (`FN_*`).\n\n' +
780
+ '`newName` is REQUIRED — backend rejects an empty name on SAVEASNEWFN.\n\n' +
781
+ 'TESTDATA — TWO-STEP: call once WITHOUT `tdCopy` to get the "Copy Test Data" prompt and ' +
782
+ 'confirm with the user; then call again with `tdCopy` set. Mirrors the FE\'s "COPY TEST ' +
783
+ 'DATA" modal (Cancel = false, Confirm = true). The tool REFUSES to clone until `tdCopy` ' +
784
+ 'is passed — the prompt step is not skippable.\n\n' +
785
+ '`tdCopy:true` clones the source version\'s testdata iterations across every environment. ' +
786
+ '`tdCopy:false` copies only the column STRUCTURE — one empty iteration (`ITR-1`) per ' +
787
+ 'environment; cells fall back to each parameter\'s project-level `defaultValue` if any.\n\n' +
788
+ 'Requires a live connection.',
766
789
  inputSchema: {
767
790
  fnId: z.string().describe('The source function id or business key (e.g. FN_123).'),
768
- newName: z.string().optional().describe('Custom title for the copy. Omit to let the backend auto-name it (`{sourceName}_copy1`).'),
769
- versions: z.array(z.string()).optional().describe('REQUIRED to actually clone. Version NAMES to clone (e.g. ["v1.0"]), or ["*"] to clone every version. Omit on the first call to get a preview + version list; ask the user which they want; then call again with the chosen value.'),
791
+ newName: z.string().min(1).describe('Name for the new function (required by backend).'),
792
+ tdCopy: z.boolean().optional().describe('REQUIRED to actually clone. Whether to clone the source version\'s testdata iterations. Omit on the first call to get the confirmation prompt; ask the user, then call again with the chosen value.'),
793
+ sourceVersionId: z.string().optional().describe('Which version of the source FN to clone; defaults to the FN\'s newest version.'),
770
794
  },
771
795
  annotations: { readOnlyHint: false },
772
- }, async ({ fnId, newName, versions }) => {
796
+ }, async ({ fnId, newName, tdCopy, sourceVersionId }) => {
773
797
  const adapter = deps.getAdapter();
774
798
  if (!adapter)
775
799
  return err(NOT_CONNECTED);
776
800
  if (!adapter.cloneFunction)
777
- return err('This product does not support function clone.');
778
- if (!adapter.getFunction)
779
- return err('This product has no reusable-function library.');
780
- // Preview step: no `versions` → fetch the source's version list via get_function (which
781
- // hydrates `versions` from `/tm/function/version/:id/:pid`) and hand it back.
782
- if (versions === undefined) {
783
- try {
784
- const fn = await adapter.getFunction(fnId);
785
- const names = (fn.versions ?? []).map((v) => v.name);
786
- if (names.length === 0) {
787
- return err(`Cannot clone ${fnId}: source has no versions to copy.`);
788
- }
789
- return ok(`**${fnId}** has ${names.length} version${names.length === 1 ? '' : 's'}: ` +
790
- `${names.map((n) => `\`${n}\``).join(', ')}.\n\n` +
791
- `**Ask the user which version(s) to clone**, then call \`fn_clone\` again:\n` +
792
- `- \`versions: ["*"]\` → clone ALL versions\n` +
793
- `- \`versions: ${JSON.stringify([names[0]])}\` → clone only that version\n` +
794
- `- \`versions: [name1, name2]\` → clone a specific subset`, { preview: true, versions: names, totalCount: names.length });
795
- }
796
- catch (e) {
797
- return err(describeError(e));
798
- }
801
+ return err('This product does not support save-as-new-function.');
802
+ // Prompt step: no `tdCopy` → hand back the "Copy Test Data" question.
803
+ if (tdCopy === undefined) {
804
+ return ok(`**COPY TEST DATA** — Do you want to copy test data to new function **${newName}** (from ${fnId})?\n\n` +
805
+ `If you do not want to copy the test data, only the column structure will be carried over (one empty ITR-1 per environment).\n\n` +
806
+ `**Ask the user**, then call \`fn_clone\` again:\n` +
807
+ `- \`tdCopy: true\` → copy iterations across all environments (Confirm)\n` +
808
+ `- \`tdCopy: false\` → structure only, empty values (Cancel)`, { preview: true, needs: 'tdCopy' });
799
809
  }
800
- // Explicit sentinel ["*"] = "all versions" — pass through as undefined to the adapter.
801
- const isAll = versions.length === 1 && versions[0] === '*';
802
- const effectiveVersions = isAll ? undefined : versions;
803
810
  try {
804
- const ref = await adapter.cloneFunction(fnId, newName, effectiveVersions);
805
- const nameNote = newName ? ` renamed to **${newName}**` : ' (auto-named by the backend)';
806
- const versionNote = isAll
807
- ? ' All versions were carried over.'
808
- : ` Only version(s) ${versions.map((v) => `**${v}**`).join(', ')} were carried over.`;
809
- return ok(`Cloned ${fnId} →${nameNote}. New id: **${ref.id}**` +
811
+ const ref = await adapter.cloneFunction(fnId, newName, {
812
+ tdCopy,
813
+ ...(sourceVersionId !== undefined ? { sourceVersionId } : {}),
814
+ });
815
+ const tdNote = tdCopy
816
+ ? ' Testdata iterations were copied across environments.'
817
+ : ' Testdata structure was carried over but values are empty (tdCopy:false).';
818
+ return ok(`Cloned ${fnId} → **${newName}**. New id: **${ref.id}**` +
810
819
  (ref.productUrl ? ` · ${ref.productUrl}` : '') +
811
- `.${versionNote}`, { savedRef: ref });
820
+ `.${tdNote}`, { savedRef: ref });
812
821
  }
813
822
  catch (e) {
814
823
  return err(describeError(e));
@@ -823,15 +832,19 @@ export function registerDiscoveryTools(server, deps) {
823
832
  'new version row with cloned steps. Mirrors the SimplifyQA UI "Save as new version" dialog ' +
824
833
  '(operation: SAVENEWVERSION, type: FN). `fnId` accepts the numeric id or business key (`FN_*`).\n\n' +
825
834
  '`versionName` must be unique for this FN — the backend rejects duplicates.\n\n' +
826
- '`tdCopy` (default TRUE) clones the source version\'s testdata iterations across every ' +
827
- 'environment. Pass `false` to copy only the iteration STRUCTURE — values are empty. That ' +
828
- 'matches the FE\'s "Cancel" button on the testdata-copy prompt.\n\n' +
835
+ 'TESTDATA — TWO-STEP: call once WITHOUT `tdCopy` to get the "Copy Test Data" prompt and ' +
836
+ 'confirm with the user; then call again with `tdCopy` set. Mirrors the FE\'s "COPY TEST ' +
837
+ 'DATA" modal (Cancel = false, Confirm = true). The tool REFUSES to snapshot until `tdCopy` ' +
838
+ 'is passed — the prompt step is not skippable.\n\n' +
839
+ '`tdCopy:true` clones the source version\'s testdata iterations across every environment. ' +
840
+ '`tdCopy:false` copies only the column STRUCTURE — one empty iteration (`ITR-1`) per ' +
841
+ 'environment; cells fall back to each parameter\'s project-level `defaultValue` if any.\n\n' +
829
842
  '`sourceVersionId` picks which version to snapshot; omit to snapshot the FN\'s newest ' +
830
843
  'version (the FN doc has no inline current-version marker). Requires a live connection.',
831
844
  inputSchema: {
832
845
  fnId: z.string().describe('The function id or business key (e.g. FN_123).'),
833
846
  versionName: z.string().min(1).describe('Name for the new version (must be unique for this FN).'),
834
- tdCopy: z.boolean().optional().describe('Clone testdata iterations from the source version. Default: true.'),
847
+ tdCopy: z.boolean().optional().describe('REQUIRED to actually snapshot. Clone testdata iterations from the source version. Omit on the first call to get the confirmation prompt; ask the user, then call again with the chosen value.'),
835
848
  sourceVersionId: z.string().optional().describe('Version to snapshot; defaults to the FN\'s newest version.'),
836
849
  },
837
850
  annotations: { readOnlyHint: false },
@@ -841,14 +854,22 @@ export function registerDiscoveryTools(server, deps) {
841
854
  return err(NOT_CONNECTED);
842
855
  if (!adapter.saveAsNewFunctionVersion)
843
856
  return err('This product does not support save-as-new-version for functions.');
844
- const effectiveTdCopy = tdCopy ?? true;
857
+ // Prompt step: no `tdCopy` → hand back the "Copy Test Data" question so the caller can
858
+ // confirm with the user before writing. Mirrors the FE's COPY TEST DATA modal.
859
+ if (tdCopy === undefined) {
860
+ return ok(`**COPY TEST DATA** — Do you want to copy test data to new version **${versionName}** of ${fnId}?\n\n` +
861
+ `If you do not want to copy the test data, only the column structure will be carried over (one empty ITR-1 per environment).\n\n` +
862
+ `**Ask the user**, then call \`fn_save_as_new_version\` again:\n` +
863
+ `- \`tdCopy: true\` → copy iterations across all environments (Confirm)\n` +
864
+ `- \`tdCopy: false\` → structure only, empty values (Cancel)`, { preview: true, needs: 'tdCopy' });
865
+ }
845
866
  try {
846
867
  const ref = await adapter.saveAsNewFunctionVersion(fnId, {
847
868
  versionName,
848
- tdCopy: effectiveTdCopy,
869
+ tdCopy,
849
870
  ...(sourceVersionId !== undefined ? { sourceVersionId } : {}),
850
871
  });
851
- const tdNote = effectiveTdCopy
872
+ const tdNote = tdCopy
852
873
  ? ' Testdata iterations were copied across environments.'
853
874
  : ' Testdata structure was carried over but values are empty (tdCopy:false).';
854
875
  return ok(`Snapshotted ${fnId} as new version **${versionName}**` +
@@ -878,8 +899,12 @@ export function registerDiscoveryTools(server, deps) {
878
899
  if (!rows.length)
879
900
  return ok('No modules found.', { modules: [], totalCount: totalOf(rows) ?? 0 });
880
901
  const text = `${countHeader(rows, 'module')}:\n\n` +
881
- '| id | name | parent |\n|---|---|---|\n' +
882
- rows.map((m) => `| ${escCell(m.id)} | ${escCell(m.name)} | ${escCell(m.parentId ?? '-')} |`).join('\n');
902
+ '| id | name | type | parent | childIds |\n|---|---|---|---|---|\n' +
903
+ rows.map((m) => {
904
+ const type = m.isRoot ? 'root' : m.parentId ? 'sub' : '-';
905
+ const children = m.childIds?.length ? m.childIds.join(', ') : '-';
906
+ return `| ${escCell(m.id)} | ${escCell(m.name)} | ${type} | ${escCell(m.parentId ?? '-')} | ${escCell(children)} |`;
907
+ }).join('\n');
883
908
  return ok(text, { modules: rows, totalCount: totalOf(rows) ?? rows.length });
884
909
  }
885
910
  catch (e) {
@@ -916,19 +941,29 @@ export function registerDiscoveryTools(server, deps) {
916
941
  return err(describeError(e));
917
942
  }
918
943
  });
944
+ const zModuleChild = z.lazy(() => z.object({
945
+ name: z.string().min(1),
946
+ description: z.string().optional(),
947
+ children: z.array(zModuleChild).optional(),
948
+ }));
919
949
  server.registerTool('create_module', {
920
- title: 'Create a module (folder) in the test-case tree',
950
+ title: 'Create a module (folder), optionally with a nested subtree, in the test-case tree',
921
951
  description: 'Create a new MODULE under the connected project. Set `isRoot:true` for a top-level folder; ' +
922
- 'otherwise pass `parentId` (resolve via list_modules). Duplicate name in the same project is ' +
923
- 'rejected server-side. SimplifyQA-only. Requires a live connection.',
952
+ 'otherwise pass `parentId` (resolve via list_modules). To create a WHOLE HIERARCHY in one call ' +
953
+ '(root + submodules + nested submodules), pass a `children` array — each child gets ' +
954
+ '`isRoot:false` and its parentId auto-wired from the tree position (do NOT set `isRoot`/`parentId` ' +
955
+ 'on child nodes). Duplicate name in the same project is rejected server-side. On mid-subtree ' +
956
+ 'failure the response carries `failedAt`; every node before it is already persisted (no ' +
957
+ 'transactional rollback upstream). SimplifyQA-only. Requires a live connection.',
924
958
  inputSchema: {
925
959
  name: z.string().min(1).describe('Module name. Must be unique within the project.'),
926
960
  projectId: z.string().describe('Target project id (see list_projects).'),
927
961
  isRoot: z.boolean().describe('True → top-level folder; false → child of `parentId`.'),
928
- parentId: z.string().optional().describe('Parent module id (required when isRoot is false).'),
962
+ parentId: z.string().optional().describe('Parent module id (required when isRoot is false; MUST be omitted when isRoot is true).'),
929
963
  description: z.string().optional().describe('Free-text description.'),
964
+ children: z.array(zModuleChild).optional().describe('Optional subtree under this module. Each child = { name, description?, children? } — no isRoot/parentId (derived).'),
930
965
  },
931
- }, async ({ name, projectId, isRoot, parentId, description }) => {
966
+ }, async ({ name, projectId, isRoot, parentId, description, children }) => {
932
967
  const adapter = deps.getAdapter();
933
968
  if (!adapter)
934
969
  return err(NOT_CONNECTED);
@@ -937,6 +972,9 @@ export function registerDiscoveryTools(server, deps) {
937
972
  if (!isRoot && !parentId) {
938
973
  return err('parentId is required when isRoot is false.');
939
974
  }
975
+ if (isRoot && parentId) {
976
+ return err('parentId must be omitted when isRoot is true — backend silently drops it and the module would land at root, not under the intended parent.');
977
+ }
940
978
  try {
941
979
  const saved = await adapter.createModule({
942
980
  name,
@@ -944,8 +982,41 @@ export function registerDiscoveryTools(server, deps) {
944
982
  isRoot,
945
983
  ...(parentId ? { parentId } : {}),
946
984
  ...(description ? { description } : {}),
985
+ ...(children?.length ? { children } : {}),
986
+ });
987
+ return ok(formatModuleSaved(saved), { saved });
988
+ }
989
+ catch (e) {
990
+ return err(describeError(e));
991
+ }
992
+ });
993
+ server.registerTool('update_module', {
994
+ title: 'Update a module (rename / edit description)',
995
+ description: 'Edit an existing MODULE — pass `id` plus any of `name`, `description`. Name must remain ' +
996
+ 'unique within the project (duplicate rejected server-side). SimplifyQA-only. Requires a ' +
997
+ 'live connection.',
998
+ inputSchema: {
999
+ id: z.string().describe('Module id (numeric or business key, e.g. MO_12).'),
1000
+ name: z.string().min(1).optional().describe('New module name.'),
1001
+ description: z.string().optional().describe('New description (pass "" to clear).'),
1002
+ projectId: z.string().optional().describe('Project id; defaults to the connected project.'),
1003
+ },
1004
+ }, async ({ id, name, description, projectId }) => {
1005
+ const adapter = deps.getAdapter();
1006
+ if (!adapter)
1007
+ return err(NOT_CONNECTED);
1008
+ if (!adapter.updateModule)
1009
+ return err('This product has no module tree.');
1010
+ if (name === undefined && description === undefined) {
1011
+ return err('Nothing to update — pass at least `name` or `description`.');
1012
+ }
1013
+ try {
1014
+ const saved = await adapter.updateModule(id, {
1015
+ ...(name !== undefined ? { name } : {}),
1016
+ ...(description !== undefined ? { description } : {}),
1017
+ ...(projectId ? { projectId } : {}),
947
1018
  });
948
- return ok(`Created module ${saved.id} (${name}).`, { saved });
1019
+ return ok(`Updated module ${saved.id || id}.`, { saved });
949
1020
  }
950
1021
  catch (e) {
951
1022
  return err(describeError(e));
@@ -1084,9 +1155,10 @@ export function registerDiscoveryTools(server, deps) {
1084
1155
  server.registerTool('suite_list', {
1085
1156
  title: 'List test suites',
1086
1157
  description: 'List the test suites in the connected product — id, name, type (Manual/Automation, where the ' +
1087
- 'provider has a suite-level type — SimplifyQA does, Walnut doesn\'t), and member (test-case) ' +
1088
- 'count. Use suite_get to read one with its ordered members, or suite_run to execute one. ' +
1089
- 'Requires a live connection (qa_connect). Read-only and product-neutral.',
1158
+ 'provider has a suite-level type — SimplifyQA does, Walnut doesn\'t), and number of test ' +
1159
+ 'cases. Use suite_get to read one with its ordered test cases, or suite_run to execute one. ' +
1160
+ 'Requires a live connection (qa_connect). Read-only and product-neutral. When presenting this ' +
1161
+ 'as a table, label the count column "Number of testcases", not "Members".',
1090
1162
  inputSchema: {},
1091
1163
  annotations: { readOnlyHint: true },
1092
1164
  }, async () => {
@@ -1098,7 +1170,7 @@ export function registerDiscoveryTools(server, deps) {
1098
1170
  if (!rows.length)
1099
1171
  return ok('No test suites found.', { suites: [], totalCount: totalOf(rows) ?? 0 });
1100
1172
  const text = `${countHeader(rows, 'suite')}:\n\n` +
1101
- '| id | name | type | test cases |\n|---|---|---|---|\n' +
1173
+ '| id | name | type | Number of testcases |\n|---|---|---|---|\n' +
1102
1174
  rows.map((s) => `| ${escCell(s.id)} | ${escCell(s.name)} | ${escCell(s.suiteType ?? '-')} | ${s.memberCount ?? '-'} |`).join('\n');
1103
1175
  return ok(text, { suites: rows, totalCount: totalOf(rows) ?? rows.length });
1104
1176
  }
@@ -1145,8 +1217,8 @@ export function registerDiscoveryTools(server, deps) {
1145
1217
  }
1146
1218
  });
1147
1219
  server.registerTool('suite_get', {
1148
- title: 'Read one test suite (with members)',
1149
- description: 'Read a single test suite — its name, description, and ORDERED member test cases — from the ' +
1220
+ title: 'Read one test suite (with test cases)',
1221
+ description: 'Read a single test suite — its name, description, and ORDERED test cases — from the ' +
1150
1222
  'connected product. Requires a live connection (qa_connect). `id` is the suite id. ' +
1151
1223
  'Product-neutral: same shape on any connected product.',
1152
1224
  inputSchema: {
@@ -1172,10 +1244,105 @@ export function registerDiscoveryTools(server, deps) {
1172
1244
  return err(describeError(e));
1173
1245
  }
1174
1246
  });
1247
+ server.registerTool('suite_create', {
1248
+ title: 'Create a new suite from a few test cases',
1249
+ description: 'Create a NEW test suite in the connected project from a small set of existing test cases. ' +
1250
+ '`tcIds` accepts numeric ids, business keys (TC_123), OR plain test case NAMES — a name is ' +
1251
+ 'resolved by searching the project (tc_list under the hood); an ambiguous name (multiple ' +
1252
+ 'matches) or one with zero matches is reported back rather than guessed. NOTE: a suite can ' +
1253
+ 'only hold test cases of ONE category (all manual OR all automated) — a mixed set is refused.\n\n' +
1254
+ 'NAME: if `name` is omitted, this tool does NOT create anything yet — it resolves the test ' +
1255
+ 'cases, SUGGESTS a suite name, and returns a preview. Show the user the suggested name and the ' +
1256
+ 'resolved test cases, ask them to confirm it or give their own, then call suite_create again ' +
1257
+ 'with `name` set (and the same `tcIds`) to actually create it. Requires a live connection.',
1258
+ inputSchema: {
1259
+ tcIds: z.array(z.string()).min(1).describe('Test case ids, business keys (TC_123), or plain names to include.'),
1260
+ name: z.string().optional().describe('Suite name. Omit to get a suggested name + preview first (does not create).'),
1261
+ description: z.string().optional().describe('Optional suite description.'),
1262
+ },
1263
+ annotations: { readOnlyHint: false },
1264
+ }, async ({ tcIds, name, description }) => {
1265
+ const adapter = deps.getAdapter();
1266
+ if (!adapter)
1267
+ return err(NOT_CONNECTED);
1268
+ if (!adapter.createSuite)
1269
+ return err('This product does not support creating suites.');
1270
+ if (!adapter.getTestCase)
1271
+ return err('This product does not support test-case read.');
1272
+ // Resolve each ref: try it as an id/business key first, else search by name.
1273
+ const resolved = [];
1274
+ const unresolved = [];
1275
+ for (const ref of tcIds) {
1276
+ try {
1277
+ const tc = await adapter.getTestCase(ref);
1278
+ resolved.push({ tcId: tc.id ?? ref, title: tc.title || ref, category: tc.category === 'manual' ? 'manual' : 'automated' });
1279
+ continue;
1280
+ }
1281
+ catch {
1282
+ // Not a direct id/key hit — fall through to a name search below.
1283
+ }
1284
+ if (adapter.listTestCases) {
1285
+ try {
1286
+ const matches = await adapter.listTestCases({ search: ref, limit: 5 });
1287
+ if (matches.length === 1) {
1288
+ const m = matches[0];
1289
+ const category = String(m.executionType ?? '').toUpperCase() === 'MANUAL' ? 'manual' : 'automated';
1290
+ resolved.push({ tcId: m.id, title: m.title, category });
1291
+ continue;
1292
+ }
1293
+ if (matches.length > 1) {
1294
+ return err(`"${ref}" matches ${matches.length} test cases — be more specific or pass the id: ` +
1295
+ matches.map((m) => `${m.title} (${m.id})`).join(', '));
1296
+ }
1297
+ }
1298
+ catch {
1299
+ // fall through to unresolved
1300
+ }
1301
+ }
1302
+ unresolved.push(ref);
1303
+ }
1304
+ if (unresolved.length) {
1305
+ return err(`Could not find a test case for: ${unresolved.join(', ')} — check the id/name, or use ` +
1306
+ 'tc_list to search first.');
1307
+ }
1308
+ // Same rule the backend enforces: a suite can only hold ONE category. Catch a mismatch here,
1309
+ // before suggesting a name / asking the user to confirm — no point previewing a suite that
1310
+ // createSuite would refuse anyway.
1311
+ const categories = new Set(resolved.map((r) => r.category));
1312
+ if (categories.size > 1) {
1313
+ const byCategory = (cat) => resolved.filter((r) => r.category === cat).map((r) => `${r.title} (${r.tcId})`).join(', ') || '(none)';
1314
+ return err('Cannot create suite: the test cases are a mix of manual and automated — a suite can only ' +
1315
+ `hold ONE category. Manual: ${byCategory('manual')}. Automated: ${byCategory('automated')}. ` +
1316
+ 'Split them into two suites, or pick only same-category test cases.');
1317
+ }
1318
+ if (!name) {
1319
+ const suggested = resolved.length === 1
1320
+ ? `${resolved[0].title} Suite`
1321
+ : `${resolved[0].title} + ${resolved.length - 1} more Suite`;
1322
+ return ok(`Resolved ${resolved.length} test case${resolved.length === 1 ? '' : 's'}: ` +
1323
+ `${resolved.map((r) => `${r.title} (${r.tcId})`).join(', ')}.\n\n` +
1324
+ `Suggested suite name: "${suggested}" — ASK the user to confirm this or give their own ` +
1325
+ `name, then call suite_create again with { name, tcIds } (reuse the same tcIds, or the ` +
1326
+ `resolved ids: ${resolved.map((r) => r.tcId).join(', ')}).`, { preview: true, suggestedName: suggested, testCases: resolved });
1327
+ }
1328
+ try {
1329
+ const ref = await adapter.createSuite({
1330
+ name,
1331
+ ...(description !== undefined ? { description } : {}),
1332
+ members: resolved.map((r, i) => ({ tcId: r.tcId, order: i + 1 })),
1333
+ });
1334
+ return ok(`Created suite **${name}**${ref.id ? ` · ${ref.id}` : ''} with ${resolved.length} test ` +
1335
+ `case${resolved.length === 1 ? '' : 's'}: ${resolved.map((r) => r.title).join(', ')}.` +
1336
+ (ref.productUrl ? ` ${ref.productUrl}` : ''), { savedRef: ref });
1337
+ }
1338
+ catch (e) {
1339
+ return err(describeError(e));
1340
+ }
1341
+ });
1175
1342
  // --- Suite membership edits (writes) --------------------------------------
1176
1343
  // Kept beside suite_list/suite_get so all suite tools live together; they mutate
1177
1344
  // via the adapter's updateSuiteMembers (→ update_suite_members provider-op).
1178
- const membersTable = (suite) => suite.members.length
1345
+ const testCasesTable = (suite) => suite.members.length
1179
1346
  ? '\n\n| order | test case |\n|---|---|\n' +
1180
1347
  suite.members.map((m) => `| ${m.order} | ${escCell(m.tcId)} |`).join('\n')
1181
1348
  : '';
@@ -1184,7 +1351,7 @@ export function registerDiscoveryTools(server, deps) {
1184
1351
  description: 'Add one or more EXISTING test cases to a suite (appended in order). `tcIds` accepts ids or ' +
1185
1352
  'business keys (e.g. TC_123) — use suite_list / tc_list to find them. NOTE: a suite can only ' +
1186
1353
  'hold test cases of ONE category (all manual OR all automated) — the product rejects a mix. ' +
1187
- 'Requires a live connection. Returns the updated suite with its members.',
1354
+ 'Requires a live connection. Returns the updated suite with its test cases.',
1188
1355
  inputSchema: {
1189
1356
  suiteId: z.string().describe('The suite id to add to (from suite_list).'),
1190
1357
  tcIds: z.array(z.string()).min(1).describe('Test case ids / business keys to add.'),
@@ -1209,8 +1376,8 @@ export function registerDiscoveryTools(server, deps) {
1209
1376
  }
1210
1377
  }));
1211
1378
  return ok(`Added ${added.length} test case${added.length === 1 ? '' : 's'} to **${suite.name}**: ` +
1212
- `${added.join(', ')} — now ${n} member${n === 1 ? '' : 's'}.` +
1213
- membersTable(suite), { suite });
1379
+ `${added.join(', ')} — now ${n} test case${n === 1 ? '' : 's'} total.` +
1380
+ testCasesTable(suite), { suite });
1214
1381
  }
1215
1382
  catch (e) {
1216
1383
  return err(describeError(e));
@@ -1219,8 +1386,8 @@ export function registerDiscoveryTools(server, deps) {
1219
1386
  server.registerTool('suite_remove_testcases', {
1220
1387
  title: 'Remove test cases from a suite',
1221
1388
  description: 'Remove one or more test cases from a suite. `tcIds` accepts ids or business keys; every ' +
1222
- 'occurrence of each is removed and the remaining members are reordered. Requires a live ' +
1223
- 'connection. Returns the updated suite with its members.',
1389
+ 'occurrence of each is removed and the remaining test cases are reordered. Requires a live ' +
1390
+ 'connection. Returns the updated suite with its test cases.',
1224
1391
  inputSchema: {
1225
1392
  suiteId: z.string().describe('The suite id to remove from.'),
1226
1393
  tcIds: z.array(z.string()).min(1).describe('Test case ids / business keys to remove.'),
@@ -1234,8 +1401,8 @@ export function registerDiscoveryTools(server, deps) {
1234
1401
  const ops = tcIds.map((tcId) => ({ op: 'remove', tcId }));
1235
1402
  const suite = await adapter.updateSuiteMembers(suiteId, ops);
1236
1403
  const n = suite.members.length;
1237
- return ok(`Removed ${tcIds.length} test case${tcIds.length === 1 ? '' : 's'} from **${suite.name}** — now ${n} member${n === 1 ? '' : 's'}.` +
1238
- membersTable(suite), { suite });
1404
+ return ok(`Removed ${tcIds.length} test case${tcIds.length === 1 ? '' : 's'} from **${suite.name}** — now ${n} test case${n === 1 ? '' : 's'} total.` +
1405
+ testCasesTable(suite), { suite });
1239
1406
  }
1240
1407
  catch (e) {
1241
1408
  return err(describeError(e));