common-qa-mcp 0.3.0 → 0.4.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 (83) hide show
  1. package/dist/adapters/factory.d.ts +1 -1
  2. package/dist/adapters/factory.d.ts.map +1 -1
  3. package/dist/adapters/factory.js +12 -29
  4. package/dist/adapters/factory.js.map +1 -1
  5. package/dist/adapters/http.js +2 -3
  6. package/dist/adapters/http.js.map +1 -1
  7. package/dist/adapters/remoteMcp/adapter.d.ts +7 -2
  8. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  9. package/dist/adapters/remoteMcp/adapter.js +9 -3
  10. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  11. package/dist/adapters/simplifyqa/adapter.d.ts +306 -23
  12. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  13. package/dist/adapters/simplifyqa/adapter.js +2033 -86
  14. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  15. package/dist/adapters/simplifyqa/mapper.d.ts +43 -11
  16. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  17. package/dist/adapters/simplifyqa/mapper.js +470 -21
  18. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  19. package/dist/adapters/types.d.ts +121 -5
  20. package/dist/adapters/types.d.ts.map +1 -1
  21. package/dist/adapters/walnut/adapter.d.ts +6 -2
  22. package/dist/adapters/walnut/adapter.d.ts.map +1 -1
  23. package/dist/adapters/walnut/adapter.js +33 -2
  24. package/dist/adapters/walnut/adapter.js.map +1 -1
  25. package/dist/adapters/walnut/mapper.d.ts +2 -1
  26. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  27. package/dist/adapters/walnut/mapper.js +13 -0
  28. package/dist/adapters/walnut/mapper.js.map +1 -1
  29. package/dist/config.d.ts +10 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +36 -0
  32. package/dist/config.js.map +1 -1
  33. package/dist/connection.d.ts.map +1 -1
  34. package/dist/connection.js +15 -7
  35. package/dist/connection.js.map +1 -1
  36. package/dist/domain/types.d.ts +485 -4
  37. package/dist/domain/types.d.ts.map +1 -1
  38. package/dist/initAgents.d.ts.map +1 -1
  39. package/dist/initAgents.js +22 -9
  40. package/dist/initAgents.js.map +1 -1
  41. package/dist/server.d.ts.map +1 -1
  42. package/dist/server.js +23 -1
  43. package/dist/server.js.map +1 -1
  44. package/dist/tools/backlog.tools.d.ts +13 -0
  45. package/dist/tools/backlog.tools.d.ts.map +1 -0
  46. package/dist/tools/backlog.tools.js +399 -0
  47. package/dist/tools/backlog.tools.js.map +1 -0
  48. package/dist/tools/defects.tools.d.ts +7 -0
  49. package/dist/tools/defects.tools.d.ts.map +1 -0
  50. package/dist/tools/defects.tools.js +82 -0
  51. package/dist/tools/defects.tools.js.map +1 -0
  52. package/dist/tools/discovery.tools.d.ts.map +1 -1
  53. package/dist/tools/discovery.tools.js +871 -55
  54. package/dist/tools/discovery.tools.js.map +1 -1
  55. package/dist/tools/markdown.d.ts +3 -3
  56. package/dist/tools/markdown.d.ts.map +1 -1
  57. package/dist/tools/markdown.js +89 -18
  58. package/dist/tools/markdown.js.map +1 -1
  59. package/dist/tools/objects.tools.d.ts +7 -0
  60. package/dist/tools/objects.tools.d.ts.map +1 -0
  61. package/dist/tools/objects.tools.js +201 -0
  62. package/dist/tools/objects.tools.js.map +1 -0
  63. package/dist/tools/recording.tools.d.ts.map +1 -1
  64. package/dist/tools/recording.tools.js +64 -5
  65. package/dist/tools/recording.tools.js.map +1 -1
  66. package/dist/tools/run.tools.d.ts.map +1 -1
  67. package/dist/tools/run.tools.js +102 -18
  68. package/dist/tools/run.tools.js.map +1 -1
  69. package/dist/tools/schemas.d.ts +21 -4
  70. package/dist/tools/schemas.d.ts.map +1 -1
  71. package/dist/tools/schemas.js +14 -1
  72. package/dist/tools/schemas.js.map +1 -1
  73. package/dist/tools/tasks.tools.d.ts +7 -0
  74. package/dist/tools/tasks.tools.d.ts.map +1 -0
  75. package/dist/tools/tasks.tools.js +75 -0
  76. package/dist/tools/tasks.tools.js.map +1 -0
  77. package/dist/tools/testcase.tools.d.ts.map +1 -1
  78. package/dist/tools/testcase.tools.js +146 -100
  79. package/dist/tools/testcase.tools.js.map +1 -1
  80. package/dist/tools/testdata.tools.d.ts.map +1 -1
  81. package/dist/tools/testdata.tools.js +99 -11
  82. package/dist/tools/testdata.tools.js.map +1 -1
  83. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ok, err } from '../mcpResult.js';
3
- import { zJournalOp } from './schemas.js';
3
+ import { zJournalOp, zRawFilter } from './schemas.js';
4
4
  import { stepsMarkdown } from './markdown.js';
5
5
  const NOT_CONNECTED = 'Not connected to a product. Use qa_connect({ url, token, projectId }) first — listing and ' +
6
6
  'reading existing test cases needs a live backend connection (the offline path only authors drafts).';
@@ -11,6 +11,50 @@ function describeError(e) {
11
11
  function escCell(v) {
12
12
  return String(v ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
13
13
  }
14
+ /** SQA adapter attaches `totalCount` as a side-band prop on the returned array (via
15
+ * mapper.withTotal). The provider interface declares plain `T[]`, so read it via a widening cast. */
16
+ function totalOf(rows) {
17
+ const t = rows.totalCount;
18
+ return typeof t === 'number' ? t : undefined;
19
+ }
20
+ /** Header line for a list tool: "Showing 100 of 350 test cases" when the backend returned a
21
+ * totalCount larger than the page, else "N test cases". Pluralises via `plural` (defaults to `s`
22
+ * suffix — pass explicit forms for "stor y/ies", etc). */
23
+ function countHeader(rows, singular, plural = singular + 's') {
24
+ const shown = rows.length;
25
+ const total = totalOf(rows) ?? shown;
26
+ const word = total === 1 ? singular : plural;
27
+ return total > shown ? `Showing ${shown} of ${total} ${word}` : `${total} ${word}`;
28
+ }
29
+ /** One-line human summary of a single version's dependency row from `delete_version`'s dry-run.
30
+ * Shape branches on `parentType`: TC has 7 ref maps (defects, suites, execplans, scenarios,
31
+ * schedulers, pipelines, workflows), FN has 3 (tcs, fns, workflows). Each map is {projectId → ids}. */
32
+ function renderVersionDepRow(dep) {
33
+ const totalIn = (m) => Object.values(m).reduce((n, arr) => n + arr.length, 0);
34
+ const parts = [];
35
+ if ('defectArr' in dep) {
36
+ const d = dep;
37
+ const bits = [
38
+ ['defect', totalIn(d.defectArr)], ['suite', totalIn(d.suiteArr)],
39
+ ['exec-plan', totalIn(d.epArr)], ['scenario', totalIn(d.scenarioArr)],
40
+ ['schedule', totalIn(d.schedulerArr)], ['pipeline', totalIn(d.pipelineArr)],
41
+ ['workflow', totalIn(d.workflowArr)],
42
+ ];
43
+ for (const [label, n] of bits)
44
+ if (n)
45
+ parts.push(`${n} ${label}${n === 1 ? '' : 's'}`);
46
+ }
47
+ else {
48
+ const d = dep;
49
+ const bits = [
50
+ ['test case', totalIn(d.tcArr)], ['function', totalIn(d.fnArr)], ['workflow', totalIn(d.wfArr)],
51
+ ];
52
+ for (const [label, n] of bits)
53
+ if (n)
54
+ parts.push(`${n} ${label}${n === 1 ? '' : 's'}`);
55
+ }
56
+ return parts.length ? `- ${parts.join(', ')}` : '- (no references)';
57
+ }
14
58
  export function registerDiscoveryTools(server, deps) {
15
59
  server.registerTool('list_projects', {
16
60
  title: 'List projects (resolve a project name to its id)',
@@ -26,30 +70,39 @@ export function registerDiscoveryTools(server, deps) {
26
70
  try {
27
71
  const rows = await adapter.listProjects();
28
72
  if (!rows.length)
29
- return ok('No projects found.', { projects: [] });
30
- const text = `${rows.length} project${rows.length === 1 ? '' : 's'}:\n\n` +
31
- '| id | name | test cases |\n|---|---|---|\n' +
32
- rows.map((p) => `| ${escCell(p.id)} | ${escCell(p.name)} | ${p.counts?.['testCases'] ?? '-'} |`).join('\n');
33
- return ok(text, { projects: rows });
73
+ return ok('No projects found.', { projects: [], totalCount: totalOf(rows) ?? 0 });
74
+ const view = rows.map(({ id: _id, ...rest }) => rest);
75
+ const text = `${countHeader(rows, 'project')}:\n\n` +
76
+ '| key | name | test cases |\n|---|---|---|\n' +
77
+ rows.map((p) => `| ${escCell(p.code)} | ${escCell(p.name)} | ${p.counts?.['testCases'] ?? '-'} |`).join('\n');
78
+ return ok(text, { projects: view, totalCount: totalOf(rows) ?? rows.length });
34
79
  }
35
80
  catch (e) {
36
81
  return err(describeError(e));
37
82
  }
38
83
  });
39
84
  server.registerTool('tc_list', {
40
- title: 'List / search existing test cases',
41
- description: 'List or search EXISTING test cases in the connected product. Requires a live connection ' +
42
- '(qa_connect). Returns a summary list — id, title, priority, status, step count. Use tc_get ' +
43
- 'to read one in full. This is a read-only, product-neutral tool: it works identically ' +
44
- 'whichever product the session is connected to.',
85
+ title: 'List / search existing test cases (NOT reusable functions)',
86
+ description: 'List or search EXISTING TEST CASES (TC_* ids) in the connected product. NOT for reusable ' +
87
+ 'functions: if the user asks for functions, FN_*, the function library, or reusable steps, ' +
88
+ 'use `list_functions` instead. Trigger words for THIS tool: "test case", "TC", "TC_", ' +
89
+ '"scenario", "script". Requires a live connection (qa_connect). Returns a summary list — id, ' +
90
+ 'title, technology, execution type, status, step count. Use `tc_get` to read one in full. ' +
91
+ 'Read-only, product-neutral.',
45
92
  inputSchema: {
46
93
  search: z.string().optional().describe('Free-text search over title / id.'),
47
94
  status: z.string().optional().describe('Filter by status.'),
48
95
  limit: z.number().int().positive().optional().describe('Max results (paging).'),
49
96
  offset: z.number().int().nonnegative().optional().describe('Result offset (paging).'),
97
+ createdBy: z.string().optional().describe('Numeric userId of the creator. Pass "me" to filter by the current caller (needs QA_MCP_USER_ID for API-key auth). Resolve names to ids via list_users first.'),
98
+ updatedBy: z.string().optional().describe('Numeric userId of the last updater. Pass "me" for the current caller. Resolve names via list_users first.'),
99
+ storyId: z.string().optional().describe('Only test cases tagged to this user story (id or business key, e.g. US_123).'),
100
+ moduleId: z.string().optional().describe('Only test cases filed under this module (id or business key, e.g. MO_12). SimplifyQA only — ignored elsewhere.'),
101
+ obsolete: z.boolean().optional().describe('True → only obsolete TCs; false → only non-obsolete. Omit to include both. The response summary always reports how many of the returned rows are obsolete.'),
102
+ filters: z.array(zRawFilter).optional().describe('Extra raw column filters for project-configured columns with no dedicated parameter above (Release/Priority/custom fields) — use the field\'s backend key as `column`.'),
50
103
  },
51
104
  annotations: { readOnlyHint: true },
52
- }, async ({ search, status, limit, offset }) => {
105
+ }, async ({ search, status, limit, offset, createdBy, updatedBy, storyId, moduleId, obsolete, filters }) => {
53
106
  const adapter = deps.getAdapter();
54
107
  if (!adapter)
55
108
  return err(NOT_CONNECTED);
@@ -59,16 +112,25 @@ export function registerDiscoveryTools(server, deps) {
59
112
  ...(status !== undefined ? { status } : {}),
60
113
  ...(limit !== undefined ? { limit } : {}),
61
114
  ...(offset !== undefined ? { offset } : {}),
115
+ ...(createdBy !== undefined ? { createdBy } : {}),
116
+ ...(updatedBy !== undefined ? { updatedBy } : {}),
117
+ ...(storyId !== undefined ? { storyId } : {}),
118
+ ...(moduleId !== undefined ? { moduleId } : {}),
119
+ ...(obsolete !== undefined ? { obsolete } : {}),
120
+ ...(filters?.length ? { filters } : {}),
62
121
  };
63
122
  const rows = await adapter.listTestCases(q);
64
123
  if (!rows.length)
65
- return ok('No test cases found.', { testCases: [] });
66
- const text = `${rows.length} test case${rows.length === 1 ? '' : 's'}:\n\n` +
67
- '| id | title | priority | status | steps |\n|---|---|---|---|---|\n' +
124
+ return ok('No test cases found.', { testCases: [], totalCount: totalOf(rows) ?? 0 });
125
+ const view = rows.map(({ id: _id, priority: _p, ...rest }) => rest);
126
+ const obsoleteCount = rows.filter((r) => r.obsolete === true).length;
127
+ const obsoleteNote = obsoleteCount ? ` · ${obsoleteCount} obsolete` : '';
128
+ const text = `${countHeader(rows, 'test case')}${obsoleteNote}:\n\n` +
129
+ '| key | title | technology | execution | status | steps | obsolete |\n|---|---|---|---|---|---|---|\n' +
68
130
  rows
69
- .map((r) => `| ${escCell(r.id)} | ${escCell(r.title)} | ${r.priority} | ${r.status ?? '-'} | ${r.stepCount ?? '-'} |`)
131
+ .map((r) => `| ${escCell(r.code)} | ${escCell(r.title)} | ${r.technologyType ?? '-'} | ${r.executionType ?? '-'} | ${r.status ?? '-'} | ${r.stepCount ?? '-'} | ${r.obsolete === true ? 'yes' : ''} |`)
70
132
  .join('\n');
71
- return ok(text, { testCases: rows });
133
+ return ok(text, { testCases: view, totalCount: totalOf(rows) ?? rows.length, obsoleteOnPage: obsoleteCount });
72
134
  }
73
135
  catch (e) {
74
136
  return err(describeError(e));
@@ -78,21 +140,53 @@ export function registerDiscoveryTools(server, deps) {
78
140
  title: 'Read one test case (with steps)',
79
141
  description: 'Read a single EXISTING test case in full — metadata plus its steps as a readable table — ' +
80
142
  'from the connected product. Requires a live connection (qa_connect). `id` accepts the ' +
81
- "product's id or business key. Product-neutral: same shape on any connected product.",
143
+ "product's id or business key. Product-neutral: same shape on any connected product.\n\n" +
144
+ 'VERSIONS: SimplifyQA TCs can have multiple versions, each with its own steps. When the TC ' +
145
+ "has more than one version and `versionId` is omitted, this tool REFUSES and lists the " +
146
+ "available versions — ASK the user which one to read, then call again with `versionId`. " +
147
+ "Pass `versionId: ''` (empty string) to accept the TC's currently-selected version. Single- " +
148
+ 'version TCs are read directly. Providers without versioning (Walnut) ignore `versionId`.',
82
149
  inputSchema: {
83
150
  id: z.string().describe('The test case id (or business key) to read.'),
151
+ versionId: z.string().optional().describe("Version to read (an id from the TC's `versions[]`). Omit to trigger the ASK-user " +
152
+ "prompt when the TC has multiple versions. Pass '' (empty string) to accept the TC's " +
153
+ 'currently-selected version without prompting.'),
84
154
  },
85
155
  annotations: { readOnlyHint: true },
86
- }, async ({ id }) => {
156
+ }, async ({ id, versionId }) => {
87
157
  const adapter = deps.getAdapter();
88
158
  if (!adapter)
89
159
  return err(NOT_CONNECTED);
90
160
  try {
91
- const tc = await adapter.getTestCase(id);
92
- const steps = tc.steps.length;
161
+ // Cheap pre-flight: read the TC without steps so we can inspect versions before deciding
162
+ // whether to prompt. Adapter's getTestCase(id) with no versionId is the natural way, and
163
+ // for SQA the version list is on the TC doc itself so this is one HTTP round-trip that
164
+ // we're going to do anyway.
165
+ const preview = await adapter.getTestCase(id);
166
+ const versions = preview.versions ?? [];
167
+ if (versions.length > 1 && versionId === undefined) {
168
+ const listing = versions
169
+ .map((v) => ` • \`${v.id}\` — **${v.name}**${v.isCurrent ? ' (current)' : ''}${v.reviewApproveStatus ? ` · ${v.reviewApproveStatus}` : ''}`)
170
+ .join('\n');
171
+ return err(`Test case ${preview.id ?? id} has ${versions.length} versions — ASK the user which ` +
172
+ `one to read, then call \`tc_get\` again with { id, versionId }. ` +
173
+ `Pass \`versionId: ''\` to accept the current version.\n\n${listing}`);
174
+ }
175
+ // versionId === '' means "explicit current" — pass undefined to the adapter (its default).
176
+ // Otherwise pass through.
177
+ const chosen = versionId === undefined || versionId === '' ? undefined : versionId;
178
+ const tc = chosen ? await adapter.getTestCase(id, chosen) : preview;
179
+ const stepCount = tc.steps.length;
180
+ const versionLine = tc.versions?.length
181
+ ? (() => {
182
+ const current = tc.versions.find((v) => v.isCurrent) ?? tc.versions[tc.versions.length - 1];
183
+ return ` · version: ${current.name}${tc.versions.length > 1 ? ` (${tc.versions.length} total)` : ''}`;
184
+ })()
185
+ : '';
93
186
  const header = `**${tc.title}**${tc.id ? ` · ${tc.id}` : ''}\n` +
94
187
  `priority: ${tc.priority} · category: ${tc.category}` +
95
- `${tc.status ? ` · status: ${tc.status}` : ''} · ${steps} step${steps === 1 ? '' : 's'}`;
188
+ `${tc.status ? ` · status: ${tc.status}` : ''} · ${stepCount} step${stepCount === 1 ? '' : 's'}` +
189
+ versionLine;
96
190
  return ok(`${header}\n\n${stepsMarkdown(tc)}`, { tc });
97
191
  }
98
192
  catch (e) {
@@ -100,19 +194,233 @@ export function registerDiscoveryTools(server, deps) {
100
194
  }
101
195
  });
102
196
  server.registerTool('tc_delete', {
103
- title: 'Delete a test case',
104
- description: 'Delete an existing test case in the connected product. It is RECOVERABLE from the product ' +
105
- 'recycle bin, not a hard delete. Requires a live connection (qa_connect). `tcId` accepts the ' +
106
- "product's id or business key. Product-neutral.",
107
- inputSchema: { tcId: z.string().describe('The test case id (or business key) to delete.') },
108
- annotations: { readOnlyHint: false },
109
- }, async ({ tcId }) => {
197
+ title: 'Delete a test case (two-step confirm)',
198
+ description: 'Soft-delete an existing test case in the connected product. TWO-STEP CONFIRM: call once ' +
199
+ 'with just `tcId` to preview the target (title + step count) and get a warning; then call ' +
200
+ 'again with `{ tcId, confirm: true }` to actually delete. Refuses to delete without ' +
201
+ "`confirm:true`. `tcId` accepts the product's id or business key. Requires a live " +
202
+ 'connection (qa_connect). Product-neutral.',
203
+ inputSchema: {
204
+ tcId: z.string().describe('The test case id (or business key) to delete.'),
205
+ confirm: z.boolean().optional().describe('Set to true on the SECOND call to actually delete.'),
206
+ },
207
+ annotations: { readOnlyHint: false, destructiveHint: true },
208
+ }, async ({ tcId, confirm }) => {
110
209
  const adapter = deps.getAdapter();
111
210
  if (!adapter)
112
211
  return err(NOT_CONNECTED);
212
+ if (!confirm) {
213
+ try {
214
+ // Delete preview: read the current version — no step-collapse hint anymore since
215
+ // the adapter's getTestCase signature dropped that arg in favor of a versionId param.
216
+ const tc = await adapter.getTestCase(tcId);
217
+ const steps = tc.steps.length;
218
+ return ok(`⚠️ About to delete test case **${tc.title}**` +
219
+ `${tc.id ? ` (${tc.id})` : ''} — ${steps} step${steps === 1 ? '' : 's'}. ` +
220
+ `To proceed, call again with { tcId: "${tcId}", confirm: true }.`, { preview: true, tc });
221
+ }
222
+ catch (e) {
223
+ return err(describeError(e));
224
+ }
225
+ }
113
226
  try {
114
227
  await adapter.deleteTestCase(tcId);
115
- return ok(`Deleted ${tcId} — recoverable from the product's recycle bin.`);
228
+ return ok(`Deleted ${tcId}.`);
229
+ }
230
+ catch (e) {
231
+ return err(describeError(e));
232
+ }
233
+ });
234
+ server.registerTool('tc_clone', {
235
+ title: 'Clone a test case (copy in place with steps, versions, testdata)',
236
+ description: 'FOR TEST CASES ONLY. NOT for reusable functions: if the user names an `FN_*` key or asks to ' +
237
+ 'clone a function, use `fn_clone` instead. Trigger words for THIS tool: "test case", "TC", ' +
238
+ '"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.',
252
+ inputSchema: {
253
+ 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.'),
256
+ },
257
+ annotations: { readOnlyHint: false },
258
+ }, async ({ tcId, newName, versions }) => {
259
+ const adapter = deps.getAdapter();
260
+ if (!adapter)
261
+ return err(NOT_CONNECTED);
262
+ 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
+ }
285
+ }
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
+ 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}**` +
296
+ (ref.productUrl ? ` · ${ref.productUrl}` : '') +
297
+ `.${versionNote}`, { savedRef: ref });
298
+ }
299
+ catch (e) {
300
+ return err(describeError(e));
301
+ }
302
+ });
303
+ server.registerTool('tc_save_as_new_version', {
304
+ title: 'Snapshot a test case as a new version',
305
+ description: 'FOR TEST CASES ONLY. NOT for reusable functions: if the user names an `FN_*` key or asks to ' +
306
+ 'snapshot / create a new version of a function, use `fn_save_as_new_version` instead. ' +
307
+ 'Trigger words for THIS tool: "test case", "TC", "TC_", "scenario", "script".\n\n' +
308
+ 'Freeze the CURRENT server state of a test case under a NEW version name — same TC id, a ' +
309
+ 'new version row with cloned steps. Mirrors the SimplifyQA UI "Save as new version" dialog ' +
310
+ '(operation: SAVENEWVERSION). `tcId` accepts the numeric id or business key (`TC_*`).\n\n' +
311
+ '`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' +
316
+ '`sourceVersionId` picks which version to snapshot; omit to snapshot the TC\'s currently ' +
317
+ 'selected version. Requires a live connection.',
318
+ inputSchema: {
319
+ tcId: z.string().describe('The test case id or business key (e.g. TC_123).'),
320
+ 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.'),
322
+ sourceVersionId: z.string().optional().describe('Version to snapshot; defaults to the TC\'s current version.'),
323
+ },
324
+ annotations: { readOnlyHint: false },
325
+ }, async ({ tcId, versionName, tdCopy, sourceVersionId }) => {
326
+ const adapter = deps.getAdapter();
327
+ if (!adapter)
328
+ return err(NOT_CONNECTED);
329
+ if (!adapter.saveAsNewVersion)
330
+ return err('This product does not support save-as-new-version.');
331
+ const effectiveTdCopy = tdCopy ?? true;
332
+ try {
333
+ const ref = await adapter.saveAsNewVersion(tcId, {
334
+ versionName,
335
+ tdCopy: effectiveTdCopy,
336
+ ...(sourceVersionId !== undefined ? { sourceVersionId } : {}),
337
+ });
338
+ const tdNote = effectiveTdCopy
339
+ ? ' Testdata iterations were copied across environments.'
340
+ : ' Testdata structure was carried over but values are empty (tdCopy:false).';
341
+ return ok(`Snapshotted ${tcId} as new version **${versionName}**` +
342
+ (ref.productUrl ? ` · ${ref.productUrl}` : '') +
343
+ `.${tdNote}`, { savedRef: ref });
344
+ }
345
+ catch (e) {
346
+ return err(describeError(e));
347
+ }
348
+ });
349
+ server.registerTool('tc_steps_to_function', {
350
+ title: 'Extract selected steps of a test case into a new reusable function',
351
+ description: 'Take a subset of the steps on an EXISTING test case and save them as a new reusable ' +
352
+ 'function (SimplifyQA STEPSTOFN operation). Mirrors the FE "Save as function" modal. ' +
353
+ 'Steps are picked by their current `seq`, resequenced 1..N in the new function, and filed ' +
354
+ 'under `moduleId`. `tcId` accepts the numeric id or business key (TC_123). Requires a live ' +
355
+ 'connection (qa_connect).\n\n' +
356
+ 'CALL THIS when the user says things like "save these steps as a function", "convert the ' +
357
+ 'first N steps to a function", "extract these steps into a function". Show the steps of the ' +
358
+ 'TC first (via tc_get) so the user can confirm which `seq`s to extract, and ASK the user ' +
359
+ 'which module to file the new function under — call `list_modules` if the module id is not ' +
360
+ 'known.\n\n' +
361
+ '`executionType` forces every picked step into the AUTOMATION or MANUAL bucket; omit to ' +
362
+ 'split by each step\'s own executionType (function will be DUO if both are present).\n\n' +
363
+ 'TESTDATA: iterations from the source TC version are copied to the new function by default. ' +
364
+ 'The backend prunes them to only the parameters the extracted steps reference (via key-based ' +
365
+ 'matching in tcfn.service.js `compareDatas` `isStepsToFn` mode), across every environment ' +
366
+ 'the source version has. Pass `copyTestData: false` if you explicitly want a bare function.\n\n' +
367
+ 'REPLACE-IN-SOURCE: pass `replaceInSource: true` to also splice a FUNCTION + FUNCTION_END ' +
368
+ 'pair back into the source TC in place of the extracted steps (mirrors the FE "Save & Replace" ' +
369
+ 'button). The extracted rows are soft-deleted; the backend automatically drops any testdata ' +
370
+ 'columns those steps owned and injects an FN cell (with the FN\'s per-env default iteration) ' +
371
+ 'into every TC iteration row. Requires a contiguous `stepSeqs` range and refuses picks that ' +
372
+ 'cross control-flow (IF/WHILE/FOR/DO) or nested FUNCTION brackets, and DUO/SYNCON TCs — those ' +
373
+ 'are v2 cases.\n\n' +
374
+ 'FAILURE MODE with replaceInSource: if the FN is created but the follow-up PUT fails, the ' +
375
+ 'error surfaces the new fn id + version id so you can retry or delete the phantom function.',
376
+ inputSchema: {
377
+ tcId: z.string().describe('The source test case id or business key (e.g. TC_123).'),
378
+ stepSeqs: z.array(z.number().int().positive()).min(1)
379
+ .describe('Current `seq` numbers of the steps to extract (e.g. [1,2,3,4] for the first four). Must be contiguous when replaceInSource is true.'),
380
+ name: z.string().min(1).describe('Name for the new function.'),
381
+ moduleId: z.number().int().positive()
382
+ .describe('Module id to file the new function under. Resolve via list_modules if unknown.'),
383
+ executionType: z.enum(['AUTOMATION', 'MANUAL']).optional()
384
+ .describe('Force all picked steps into this bucket; omit to split by each step\'s own executionType.'),
385
+ copyTestData: z.boolean().optional()
386
+ .describe('Copy the source version\'s testdata iterations to the new function (backend prunes to only the params the extracted steps use). Default: true.'),
387
+ replaceInSource: z.boolean().optional()
388
+ .describe('Replace the extracted steps on the source TC with a FUNCTION + FUNCTION_END call to the new function. Default: false (create-only). Requires contiguous stepSeqs and refuses DUO/SYNCON/control-flow spans.'),
389
+ },
390
+ annotations: { readOnlyHint: false },
391
+ }, async ({ tcId, stepSeqs, name, moduleId, executionType, copyTestData, replaceInSource }) => {
392
+ const adapter = deps.getAdapter();
393
+ if (!adapter)
394
+ return err(NOT_CONNECTED);
395
+ if (!adapter.saveStepsAsFunction) {
396
+ return err('This product does not support extracting steps to a function.');
397
+ }
398
+ const tdCopy = copyTestData ?? true;
399
+ const tdNote = tdCopy
400
+ ? ' Testdata iterations were copied (pruned to the extracted steps\' parameters).'
401
+ : ' No testdata was copied.';
402
+ if (replaceInSource) {
403
+ if (!adapter.extractStepsAsFunctionAndReplace) {
404
+ return err('This product does not support replace-in-source for step-to-function extraction.');
405
+ }
406
+ try {
407
+ const ref = await adapter.extractStepsAsFunctionAndReplace(tcId, {
408
+ stepSeqs, name, moduleId, copyTestData: tdCopy,
409
+ ...(executionType !== undefined ? { executionType } : {}),
410
+ });
411
+ return ok(`Created function **${name}** (id: ${ref.fnId}, version: ${ref.fnVersionId}) and replaced ` +
412
+ `steps [${stepSeqs.join(', ')}] on ${tcId} with a FUNCTION call.${tdNote}`, { savedRef: ref });
413
+ }
414
+ catch (e) {
415
+ return err(describeError(e));
416
+ }
417
+ }
418
+ try {
419
+ const ref = await adapter.saveStepsAsFunction(tcId, {
420
+ stepSeqs, name, moduleId, copyTestData: tdCopy,
421
+ ...(executionType !== undefined ? { executionType } : {}),
422
+ });
423
+ return ok(`Created function **${name}** (id: ${ref.id}) from ${tcId} steps [${stepSeqs.join(', ')}].${tdNote}`, { savedRef: ref });
116
424
  }
117
425
  catch (e) {
118
426
  return err(describeError(e));
@@ -131,9 +439,13 @@ export function registerDiscoveryTools(server, deps) {
131
439
  offset: z.number().int().nonnegative().optional(),
132
440
  sprintId: z.string().optional().describe('Only stories that belong to this sprint (from list_sprints).'),
133
441
  releaseId: z.string().optional().describe('Only stories that belong to this release (from list_releases).'),
442
+ createdBy: z.string().optional().describe('Numeric userId of the creator. Pass "me" to filter by the current caller (needs QA_MCP_USER_ID for API-key auth). Resolve names to ids via list_users first.'),
443
+ updatedBy: z.string().optional().describe('Numeric userId of the last updater. Pass "me" for the current caller. Resolve names via list_users first.'),
444
+ ownerId: z.string().optional().describe('Numeric userId of the story OWNER (assignee). Pass "me" for the current caller. Resolve names via list_users first.'),
445
+ filters: z.array(zRawFilter).optional().describe('Extra raw column filters for project-configured columns with no dedicated parameter above (Feature/Epic/Priority/custom fields) — use the field\'s backend key as `column`.'),
134
446
  },
135
447
  annotations: { readOnlyHint: true },
136
- }, async ({ search, status, limit, offset, sprintId, releaseId }) => {
448
+ }, async ({ search, status, limit, offset, sprintId, releaseId, createdBy, updatedBy, ownerId, filters }) => {
137
449
  const adapter = deps.getAdapter();
138
450
  if (!adapter)
139
451
  return err(NOT_CONNECTED);
@@ -147,13 +459,18 @@ export function registerDiscoveryTools(server, deps) {
147
459
  ...(offset != null ? { offset } : {}),
148
460
  ...(sprintId ? { sprintId } : {}),
149
461
  ...(releaseId ? { releaseId } : {}),
462
+ ...(createdBy ? { createdBy } : {}),
463
+ ...(updatedBy ? { updatedBy } : {}),
464
+ ...(ownerId ? { ownerId } : {}),
465
+ ...(filters?.length ? { filters } : {}),
150
466
  });
151
467
  if (!rows.length)
152
- return ok('No user stories found.', { stories: [] });
153
- const text = `${rows.length} stor${rows.length === 1 ? 'y' : 'ies'}:\n\n` +
468
+ return ok('No user stories found.', { stories: [], totalCount: totalOf(rows) ?? 0 });
469
+ const view = rows.map(({ id: _id, ...rest }) => rest);
470
+ const text = `${countHeader(rows, 'story', 'stories')}:\n\n` +
154
471
  '| key | title | status |\n|---|---|---|\n' +
155
472
  rows.map((s) => `| ${escCell(s.key ?? s.id)} | ${escCell(s.title)} | ${escCell(s.status ?? '-')} |`).join('\n');
156
- return ok(text, { stories: rows });
473
+ return ok(text, { stories: view, totalCount: totalOf(rows) ?? rows.length });
157
474
  }
158
475
  catch (e) {
159
476
  return err(describeError(e));
@@ -205,13 +522,13 @@ export function registerDiscoveryTools(server, deps) {
205
522
  try {
206
523
  const rows = await adapter.listReleases({ scope });
207
524
  if (!rows.length)
208
- return ok(`No releases found (scope: ${scope}).`, { releases: [] });
209
- const text = `${rows.length} release${rows.length === 1 ? '' : 's'} (scope: ${scope}):\n\n` +
525
+ return ok(`No releases found (scope: ${scope}).`, { releases: [], totalCount: totalOf(rows) ?? 0 });
526
+ const text = `${countHeader(rows, 'release')} (scope: ${scope}):\n\n` +
210
527
  '| id | name | state | planned start | planned end |\n|---|---|---|---|---|\n' +
211
528
  rows
212
529
  .map((r) => `| ${escCell(r.id)} | ${escCell(r.name)} | ${escCell(r.state ?? '-')} | ${escCell(r.plannedStartDate ?? '-')} | ${escCell(r.plannedEndDate ?? '-')} |`)
213
530
  .join('\n');
214
- return ok(text, { releases: rows });
531
+ return ok(text, { releases: rows, totalCount: totalOf(rows) ?? rows.length });
215
532
  }
216
533
  catch (e) {
217
534
  return err(describeError(e));
@@ -236,13 +553,266 @@ export function registerDiscoveryTools(server, deps) {
236
553
  try {
237
554
  const rows = await adapter.listSprints(releaseId !== undefined ? { releaseId } : {});
238
555
  if (!rows.length)
239
- return ok('No sprints found.', { sprints: [] });
240
- const text = `${rows.length} sprint${rows.length === 1 ? '' : 's'}${releaseId ? ` (release ${releaseId})` : ''}:\n\n` +
556
+ return ok('No sprints found.', { sprints: [], totalCount: totalOf(rows) ?? 0 });
557
+ const text = `${countHeader(rows, 'sprint')}${releaseId ? ` (release ${releaseId})` : ''}:\n\n` +
241
558
  '| id | name | release | state | planned start | planned end |\n|---|---|---|---|---|---|\n' +
242
559
  rows
243
560
  .map((s) => `| ${escCell(s.id)} | ${escCell(s.name)} | ${escCell(s.releaseId ?? '-')} | ${escCell(s.state ?? '-')} | ${escCell(s.plannedStartDate ?? '-')} | ${escCell(s.plannedEndDate ?? '-')} |`)
244
561
  .join('\n');
245
- return ok(text, { sprints: rows });
562
+ return ok(text, { sprints: rows, totalCount: totalOf(rows) ?? rows.length });
563
+ }
564
+ catch (e) {
565
+ return err(describeError(e));
566
+ }
567
+ });
568
+ server.registerTool('list_functions', {
569
+ title: 'List / search reusable functions (NOT test cases)',
570
+ description: 'List reusable FUNCTIONS in the connected project — the FN_* library of building blocks that ' +
571
+ 'test cases call as nested steps. NOT test cases: if the user asks for test cases, TCs, ' +
572
+ 'scenarios, or scripts, use `tc_list` instead. Trigger words for THIS tool: "functions", ' +
573
+ '"reusable functions", "function library", "FN_", "shared steps", "building blocks". Returns ' +
574
+ 'id, key (FN_*), name, description, technology (WEB/API/ANDROID/IOS), execution type ' +
575
+ '(MANUAL/AUTOMATION), review status, step count. Use `get_function` to read one in full ' +
576
+ '(with its steps) and `delete_function` to soft-delete. SimplifyQA-only. Requires a live ' +
577
+ 'connection (qa_connect). Read-only.',
578
+ inputSchema: {
579
+ search: z.string().optional().describe('Free-text search over the function name.'),
580
+ fnType: z.string().optional().describe('Filter by technology (WEB / API / ANDROID / IOS).'),
581
+ executionType: z.string().optional().describe('Filter by execution type (MANUAL / AUTOMATION).'),
582
+ moduleId: z.string().optional().describe('Only functions filed under this module (id or business key, e.g. MO_12).'),
583
+ createdBy: z.string().optional().describe('Numeric userId of the creator. Pass "me" to filter by the current caller (needs QA_MCP_USER_ID for API-key auth). Resolve names to ids via list_users first.'),
584
+ updatedBy: z.string().optional().describe('Numeric userId of the last updater. Pass "me" for the current caller. Resolve names via list_users first.'),
585
+ limit: z.number().int().positive().optional().describe('Max results (paging).'),
586
+ offset: z.number().int().nonnegative().optional().describe('Result offset (paging).'),
587
+ },
588
+ annotations: { readOnlyHint: true },
589
+ }, async ({ search, fnType, executionType, moduleId, createdBy, updatedBy, limit, offset }) => {
590
+ const adapter = deps.getAdapter();
591
+ if (!adapter)
592
+ return err(NOT_CONNECTED);
593
+ if (!adapter.listFunctions)
594
+ return err('This product has no reusable-function library.');
595
+ try {
596
+ const rows = await adapter.listFunctions({
597
+ ...(search !== undefined ? { search } : {}),
598
+ ...(fnType !== undefined ? { fnType } : {}),
599
+ ...(executionType !== undefined ? { executionType } : {}),
600
+ ...(moduleId !== undefined ? { moduleId } : {}),
601
+ ...(createdBy !== undefined ? { createdBy } : {}),
602
+ ...(updatedBy !== undefined ? { updatedBy } : {}),
603
+ ...(limit !== undefined ? { limit } : {}),
604
+ ...(offset !== undefined ? { offset } : {}),
605
+ });
606
+ if (!rows.length)
607
+ return ok('No functions found.', { functions: [], totalCount: totalOf(rows) ?? 0 });
608
+ const view = rows.map(({ id: _id, ...rest }) => rest);
609
+ const text = `${countHeader(rows, 'function')}:\n\n` +
610
+ '| key | name | technology | execution | status | steps |\n|---|---|---|---|---|---|\n' +
611
+ rows
612
+ .map((f) => `| ${escCell(f.code)} | ${escCell(f.name)} | ${f.fnType ?? '-'} | ${f.executionType ?? '-'} | ${f.status ?? '-'} | ${f.stepCount ?? '-'} |`)
613
+ .join('\n');
614
+ return ok(text, { functions: view, totalCount: totalOf(rows) ?? rows.length });
615
+ }
616
+ catch (e) {
617
+ return err(describeError(e));
618
+ }
619
+ });
620
+ server.registerTool('get_function', {
621
+ title: 'Read / explain one reusable function (NOT a test case)',
622
+ description: 'Read a single reusable FUNCTION in full — metadata, description, and steps as a readable ' +
623
+ "table — from the connected product. `id` accepts the function's numeric id or its business key " +
624
+ '(e.g. FN_123). NOT for test cases: if the user asks to read / explain a TC or TC_* id, use ' +
625
+ '`tc_get` instead. Trigger words for THIS tool: "function", "FN_", "reusable function", ' +
626
+ '"explain this function". Use this to explain what a function does before calling it from a ' +
627
+ 'test case or before deleting it. Requires a live connection (qa_connect).',
628
+ inputSchema: {
629
+ id: z.string().describe('The function id (or business key like FN_123) to read.'),
630
+ },
631
+ annotations: { readOnlyHint: true },
632
+ }, async ({ id }) => {
633
+ const adapter = deps.getAdapter();
634
+ if (!adapter)
635
+ return err(NOT_CONNECTED);
636
+ if (!adapter.getFunction)
637
+ return err('This product has no reusable-function library.');
638
+ try {
639
+ const fn = await adapter.getFunction(id);
640
+ const steps = fn.steps.length;
641
+ const versionCount = fn.versions?.length ?? 0;
642
+ const header = `**${fn.name}**${fn.code ? ` · ${fn.code}` : fn.id ? ` · ${fn.id}` : ''}\n` +
643
+ `technology: ${fn.fnType ?? '-'} · execution: ${fn.executionType ?? '-'}` +
644
+ `${fn.status ? ` · status: ${fn.status}` : ''} · ${steps} step${steps === 1 ? '' : 's'}` +
645
+ `${versionCount ? ` · ${versionCount} version${versionCount === 1 ? '' : 's'}: ${fn.versions.map((v) => v.name).join(', ')}` : ''}` +
646
+ `${fn.description ? `\n\n${fn.description}` : ''}`;
647
+ const body = steps
648
+ ? '\n\n| # | type | description |\n|---|---|---|\n' +
649
+ fn.steps
650
+ .map((s) => `| ${s.n} | ${escCell(s.stepType)}${s.controlFlow ? `/${s.controlFlow.kind}` : ''} | ${escCell(s.description)} |`)
651
+ .join('\n')
652
+ : '\n\n_(no steps)_';
653
+ const versionsBlock = versionCount
654
+ ? '\n\n**Versions** (newest first):\n\n| name | id | current | execution | review |\n|---|---|---|---|---|\n' +
655
+ fn.versions
656
+ .map((v) => `| ${escCell(v.name)} | \`${v.id}\` | ${v.isCurrent ? '✓' : ''} | ${v.executionType ?? '-'} | ${v.reviewApproveStatus ?? '-'} |`)
657
+ .join('\n')
658
+ : '';
659
+ return ok(`${header}${body}${versionsBlock}`, { function: fn });
660
+ }
661
+ catch (e) {
662
+ return err(describeError(e));
663
+ }
664
+ });
665
+ server.registerTool('delete_function', {
666
+ title: 'Delete a reusable function (NOT a test case; two-step confirm)',
667
+ description: 'Soft-delete a reusable FUNCTION in the connected product. NOT for test cases: if the user ' +
668
+ 'asks to delete a TC or TC_* id, use `tc_delete` instead. Trigger words for THIS tool: ' +
669
+ '"function", "FN_", "reusable function". TWO-STEP CONFIRM: call once with just `id` to preview ' +
670
+ 'the target (name + step count) and get a warning; then call again with `{ id, confirm: true }` ' +
671
+ "to actually delete. Refuses to delete without `confirm:true`. `id` accepts the function's " +
672
+ 'numeric id or business key. The backend refuses to delete a function that is still referenced ' +
673
+ 'by a test case, another function, or a workflow — this tool surfaces those references so the ' +
674
+ 'user can remove them first.',
675
+ inputSchema: {
676
+ id: z.string().describe('The function id (or business key like FN_123) to delete.'),
677
+ confirm: z.boolean().optional().describe('Set to true on the SECOND call to actually delete.'),
678
+ },
679
+ annotations: { readOnlyHint: false, destructiveHint: true },
680
+ }, async ({ id, confirm }) => {
681
+ const adapter = deps.getAdapter();
682
+ if (!adapter)
683
+ return err(NOT_CONNECTED);
684
+ if (!adapter.getFunction || !adapter.deleteFunction) {
685
+ return err('This product has no reusable-function library.');
686
+ }
687
+ if (!confirm) {
688
+ try {
689
+ const fn = await adapter.getFunction(id);
690
+ return ok(`⚠️ About to delete function **${fn.name}**` +
691
+ `${fn.code ? ` (${fn.code})` : ''} — ${fn.steps.length} step${fn.steps.length === 1 ? '' : 's'}. ` +
692
+ `To proceed, call again with { id: "${id}", confirm: true }. Deletion will be refused if ` +
693
+ 'the function is still referenced by a test case, another function, or a workflow.', { preview: true, function: fn });
694
+ }
695
+ catch (e) {
696
+ return err(describeError(e));
697
+ }
698
+ }
699
+ try {
700
+ await adapter.deleteFunction(id);
701
+ return ok(`Deleted function ${id}.`);
702
+ }
703
+ catch (e) {
704
+ return err(describeError(e));
705
+ }
706
+ });
707
+ server.registerTool('fn_clone', {
708
+ title: 'Clone a reusable function (copy in place with steps, versions, testdata)',
709
+ description: 'FOR REUSABLE FUNCTIONS ONLY. NOT for test cases: if the user names a `TC_*` key or asks to ' +
710
+ 'clone a test case / scenario / script, use `tc_clone` instead. Trigger words for THIS tool: ' +
711
+ '"function", "reusable function", "FN_", "FN-".\n\n' +
712
+ 'Save an existing reusable FUNCTION as a NEW copy in the SAME project + module — all steps, ' +
713
+ 'versions, test data (all environments), parameters, object refs, and nested function calls ' +
714
+ "are carried over. Uses SimplifyQA's native TC/FN copy endpoint with `type:'FN'`. `fnId` " +
715
+ "accepts the product's id or business key (`FN_*`).\n\n" +
716
+ 'Naming: pass `newName` for a custom title; otherwise the backend auto-names the copy as ' +
717
+ '`{sourceName}_copy1`, `_copy2`, etc.\n\n' +
718
+ 'VERSIONS — TWO-STEP: call once WITHOUT `versions` to get a preview of the source\'s ' +
719
+ 'versions and confirm with the user which to clone; then call again with `versions` set. ' +
720
+ '`versions:["*"]` clones every version; `versions:["v1.0","v2.0",...]` clones only those. ' +
721
+ 'The tool REFUSES to clone until `versions` is passed — the preview step is not skippable.\n\n' +
722
+ 'PERMISSION: requires the `copy_function_across_projects` privilege on the project (admins ' +
723
+ "have it by default; other roles need it granted). If you get a permission error, ask your " +
724
+ 'project admin. Requires a live connection.',
725
+ inputSchema: {
726
+ fnId: z.string().describe('The source function id or business key (e.g. FN_123).'),
727
+ newName: z.string().optional().describe('Custom title for the copy. Omit to let the backend auto-name it (`{sourceName}_copy1`).'),
728
+ 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.'),
729
+ },
730
+ annotations: { readOnlyHint: false },
731
+ }, async ({ fnId, newName, versions }) => {
732
+ const adapter = deps.getAdapter();
733
+ if (!adapter)
734
+ return err(NOT_CONNECTED);
735
+ if (!adapter.cloneFunction)
736
+ return err('This product does not support function clone.');
737
+ if (!adapter.getFunction)
738
+ return err('This product has no reusable-function library.');
739
+ // Preview step: no `versions` → fetch the source's version list via get_function (which
740
+ // hydrates `versions` from `/tm/function/version/:id/:pid`) and hand it back.
741
+ if (versions === undefined) {
742
+ try {
743
+ const fn = await adapter.getFunction(fnId);
744
+ const names = (fn.versions ?? []).map((v) => v.name);
745
+ if (names.length === 0) {
746
+ return err(`Cannot clone ${fnId}: source has no versions to copy.`);
747
+ }
748
+ return ok(`**${fnId}** has ${names.length} version${names.length === 1 ? '' : 's'}: ` +
749
+ `${names.map((n) => `\`${n}\``).join(', ')}.\n\n` +
750
+ `**Ask the user which version(s) to clone**, then call \`fn_clone\` again:\n` +
751
+ `- \`versions: ["*"]\` → clone ALL versions\n` +
752
+ `- \`versions: ${JSON.stringify([names[0]])}\` → clone only that version\n` +
753
+ `- \`versions: [name1, name2]\` → clone a specific subset`, { preview: true, versions: names, totalCount: names.length });
754
+ }
755
+ catch (e) {
756
+ return err(describeError(e));
757
+ }
758
+ }
759
+ // Explicit sentinel ["*"] = "all versions" — pass through as undefined to the adapter.
760
+ const isAll = versions.length === 1 && versions[0] === '*';
761
+ const effectiveVersions = isAll ? undefined : versions;
762
+ try {
763
+ const ref = await adapter.cloneFunction(fnId, newName, effectiveVersions);
764
+ const nameNote = newName ? ` renamed to **${newName}**` : ' (auto-named by the backend)';
765
+ const versionNote = isAll
766
+ ? ' All versions were carried over.'
767
+ : ` Only version(s) ${versions.map((v) => `**${v}**`).join(', ')} were carried over.`;
768
+ return ok(`Cloned ${fnId} →${nameNote}. New id: **${ref.id}**` +
769
+ (ref.productUrl ? ` · ${ref.productUrl}` : '') +
770
+ `.${versionNote}`, { savedRef: ref });
771
+ }
772
+ catch (e) {
773
+ return err(describeError(e));
774
+ }
775
+ });
776
+ server.registerTool('fn_save_as_new_version', {
777
+ title: 'Snapshot a reusable function as a new version',
778
+ description: 'FOR REUSABLE FUNCTIONS ONLY. NOT for test cases: if the user names a `TC_*` key or asks to ' +
779
+ 'snapshot / create a new version of a test case, use `tc_save_as_new_version` instead. ' +
780
+ 'Trigger words for THIS tool: "function", "reusable function", "FN_", "FN-".\n\n' +
781
+ 'Freeze the CURRENT server state of a reusable FUNCTION under a NEW version name — same FN id, a ' +
782
+ 'new version row with cloned steps. Mirrors the SimplifyQA UI "Save as new version" dialog ' +
783
+ '(operation: SAVENEWVERSION, type: FN). `fnId` accepts the numeric id or business key (`FN_*`).\n\n' +
784
+ '`versionName` must be unique for this FN — the backend rejects duplicates.\n\n' +
785
+ '`tdCopy` (default TRUE) clones the source version\'s testdata iterations across every ' +
786
+ 'environment. Pass `false` to copy only the iteration STRUCTURE — values are empty. That ' +
787
+ 'matches the FE\'s "Cancel" button on the testdata-copy prompt.\n\n' +
788
+ '`sourceVersionId` picks which version to snapshot; omit to snapshot the FN\'s newest ' +
789
+ 'version (the FN doc has no inline current-version marker). Requires a live connection.',
790
+ inputSchema: {
791
+ fnId: z.string().describe('The function id or business key (e.g. FN_123).'),
792
+ versionName: z.string().min(1).describe('Name for the new version (must be unique for this FN).'),
793
+ tdCopy: z.boolean().optional().describe('Clone testdata iterations from the source version. Default: true.'),
794
+ sourceVersionId: z.string().optional().describe('Version to snapshot; defaults to the FN\'s newest version.'),
795
+ },
796
+ annotations: { readOnlyHint: false },
797
+ }, async ({ fnId, versionName, tdCopy, sourceVersionId }) => {
798
+ const adapter = deps.getAdapter();
799
+ if (!adapter)
800
+ return err(NOT_CONNECTED);
801
+ if (!adapter.saveAsNewFunctionVersion)
802
+ return err('This product does not support save-as-new-version for functions.');
803
+ const effectiveTdCopy = tdCopy ?? true;
804
+ try {
805
+ const ref = await adapter.saveAsNewFunctionVersion(fnId, {
806
+ versionName,
807
+ tdCopy: effectiveTdCopy,
808
+ ...(sourceVersionId !== undefined ? { sourceVersionId } : {}),
809
+ });
810
+ const tdNote = effectiveTdCopy
811
+ ? ' Testdata iterations were copied across environments.'
812
+ : ' Testdata structure was carried over but values are empty (tdCopy:false).';
813
+ return ok(`Snapshotted ${fnId} as new version **${versionName}**` +
814
+ (ref.productUrl ? ` · ${ref.productUrl}` : '') +
815
+ `.${tdNote}`, { savedRef: ref });
246
816
  }
247
817
  catch (e) {
248
818
  return err(describeError(e));
@@ -250,9 +820,10 @@ export function registerDiscoveryTools(server, deps) {
250
820
  });
251
821
  server.registerTool('list_modules', {
252
822
  title: 'List modules / folders (SimplifyQA test-case tree)',
253
- description: 'List the MODULES (folder tree) test cases can be filed under in the connected project. ' +
254
- 'ASK the user which module to file a draft under before tc_draft_create — pass the chosen id ' +
255
- 'as `folderOrModule`. SimplifyQA-only: Walnut has no module tree. Requires a live connection.',
823
+ description: 'List the MODULES (folder tree) in the connected project — id, name, parent. Use for any ' +
824
+ '"list / show / what modules" request. Also the source of the `folderOrModule` id passed to ' +
825
+ 'tc_draft_create (ask the user which one before drafting). SimplifyQA-only: Walnut has no ' +
826
+ 'module tree. Requires a live connection.',
256
827
  inputSchema: {},
257
828
  annotations: { readOnlyHint: true },
258
829
  }, async () => {
@@ -264,11 +835,206 @@ export function registerDiscoveryTools(server, deps) {
264
835
  try {
265
836
  const rows = await adapter.listModules();
266
837
  if (!rows.length)
267
- return ok('No modules found.', { modules: [] });
268
- const text = `${rows.length} module${rows.length === 1 ? '' : 's'}:\n\n` +
838
+ return ok('No modules found.', { modules: [], totalCount: totalOf(rows) ?? 0 });
839
+ const text = `${countHeader(rows, 'module')}:\n\n` +
269
840
  '| id | name | parent |\n|---|---|---|\n' +
270
841
  rows.map((m) => `| ${escCell(m.id)} | ${escCell(m.name)} | ${escCell(m.parentId ?? '-')} |`).join('\n');
271
- return ok(text, { modules: rows });
842
+ return ok(text, { modules: rows, totalCount: totalOf(rows) ?? rows.length });
843
+ }
844
+ catch (e) {
845
+ return err(describeError(e));
846
+ }
847
+ });
848
+ server.registerTool('get_module', {
849
+ title: 'View module details',
850
+ description: 'Fetch full detail for one MODULE — id, name, code, description, parentId, childIds. Use ' +
851
+ 'when list_modules\' summary row isn\'t enough (need description or child tree). SimplifyQA-only. ' +
852
+ 'Requires a live connection.',
853
+ inputSchema: {
854
+ id: z.string().describe('Module id (numeric or business key resolvable via list_modules).'),
855
+ },
856
+ annotations: { readOnlyHint: true },
857
+ }, async ({ id }) => {
858
+ const adapter = deps.getAdapter();
859
+ if (!adapter)
860
+ return err(NOT_CONNECTED);
861
+ if (!adapter.getModule)
862
+ return err('This product has no module tree.');
863
+ try {
864
+ const m = await adapter.getModule(id);
865
+ const lines = [
866
+ `**Module ${m.id}** — ${m.name}`,
867
+ m.code ? `Code: ${m.code}` : undefined,
868
+ m.parentId ? `Parent: ${m.parentId}` : 'Parent: (root)',
869
+ m.description ? `Description: ${m.description}` : undefined,
870
+ m.childIds?.length ? `Child module ids: ${m.childIds.join(', ')}` : undefined,
871
+ ].filter(Boolean);
872
+ return ok(lines.join('\n'), { module: m });
873
+ }
874
+ catch (e) {
875
+ return err(describeError(e));
876
+ }
877
+ });
878
+ server.registerTool('create_module', {
879
+ title: 'Create a module (folder) in the test-case tree',
880
+ description: 'Create a new MODULE under the connected project. Set `isRoot:true` for a top-level folder; ' +
881
+ 'otherwise pass `parentId` (resolve via list_modules). Duplicate name in the same project is ' +
882
+ 'rejected server-side. SimplifyQA-only. Requires a live connection.',
883
+ inputSchema: {
884
+ name: z.string().min(1).describe('Module name. Must be unique within the project.'),
885
+ projectId: z.string().describe('Target project id (see list_projects).'),
886
+ isRoot: z.boolean().describe('True → top-level folder; false → child of `parentId`.'),
887
+ parentId: z.string().optional().describe('Parent module id (required when isRoot is false).'),
888
+ description: z.string().optional().describe('Free-text description.'),
889
+ },
890
+ }, async ({ name, projectId, isRoot, parentId, description }) => {
891
+ const adapter = deps.getAdapter();
892
+ if (!adapter)
893
+ return err(NOT_CONNECTED);
894
+ if (!adapter.createModule)
895
+ return err('This product has no module tree.');
896
+ if (!isRoot && !parentId) {
897
+ return err('parentId is required when isRoot is false.');
898
+ }
899
+ try {
900
+ const saved = await adapter.createModule({
901
+ name,
902
+ projectId,
903
+ isRoot,
904
+ ...(parentId ? { parentId } : {}),
905
+ ...(description ? { description } : {}),
906
+ });
907
+ return ok(`Created module ${saved.id} (${name}).`, { saved });
908
+ }
909
+ catch (e) {
910
+ return err(describeError(e));
911
+ }
912
+ });
913
+ server.registerTool('delete_module', {
914
+ title: 'Delete modules (with dependency dry-run)',
915
+ description: 'Delete one or more MODULES by id. Two phases: call with `confirm:false` (default) for a ' +
916
+ 'DRY RUN that returns each id\'s dependency footprint (test cases, functions, parameters, ' +
917
+ 'objects, defects, user stories that still reference the module). Call again with ' +
918
+ '`confirm:true` to actually delete; the server drops only ids with EMPTY dependencies and ' +
919
+ 'silently skips the rest — surface those to the caller so they can move/remove the refs first. ' +
920
+ 'SimplifyQA-only. Requires a live connection.',
921
+ inputSchema: {
922
+ ids: z.array(z.string()).min(1).describe('Module ids to delete.'),
923
+ projectId: z.string().describe('Project id the modules belong to.'),
924
+ confirm: z.boolean().optional().describe('False (default) → dry-run; true → apply delete.'),
925
+ },
926
+ annotations: { destructiveHint: true },
927
+ }, async ({ ids, projectId, confirm }) => {
928
+ const adapter = deps.getAdapter();
929
+ if (!adapter)
930
+ return err(NOT_CONNECTED);
931
+ if (!adapter.deleteModule)
932
+ return err('This product has no module tree.');
933
+ try {
934
+ const result = await adapter.deleteModule({ ids, projectId, ...(confirm !== undefined ? { confirm } : {}) });
935
+ const blockers = result.dependencies.filter((d) => d.tcArr.length || d.fnArr.length || d.parameterArr.length || d.objArr.length || d.defectArr.length || d.usArr.length);
936
+ if (result.dryRun) {
937
+ if (!blockers.length) {
938
+ return ok(`Dry-run: ${ids.length} module(s) have NO dependencies — safe to delete. ` +
939
+ 'Call again with confirm:true to apply.', { result });
940
+ }
941
+ const text = `Dry-run: ${blockers.length} of ${ids.length} module(s) still have references. ` +
942
+ 'Deleting with confirm:true will SKIP these ids:\n\n' +
943
+ '| id | test cases | functions | params | objects | defects | user stories |\n' +
944
+ '|---|---|---|---|---|---|---|\n' +
945
+ blockers
946
+ .map((d) => `| ${d.id} | ${d.tcArr.length} | ${d.fnArr.length} | ${d.parameterArr.length} | ${d.objArr.length} | ${d.defectArr.length} | ${d.usArr.length} |`)
947
+ .join('\n');
948
+ return ok(text, { result });
949
+ }
950
+ if (blockers.length) {
951
+ return ok(`Deleted ${ids.length - blockers.length} module(s). ${blockers.length} SKIPPED (still referenced). ` +
952
+ 'Move or remove the referring rows, then retry those ids.', { result });
953
+ }
954
+ return ok(`Deleted ${ids.length} module(s).`, { result });
955
+ }
956
+ catch (e) {
957
+ return err(describeError(e));
958
+ }
959
+ });
960
+ server.registerTool('delete_version', {
961
+ title: 'Delete a version of a test case or function (two-step confirm)',
962
+ description: 'Soft-delete ONE version of a test case (`kind:"tc"`) or a reusable function (`kind:"fn"`) ' +
963
+ 'in the connected product. `versionId` comes from the parent\'s `versions[]` (populated by ' +
964
+ '`tc_get` for TCs and `get_function` for FNs); pass the version\'s `name` too (backend ' +
965
+ 'validator requires it). TWO-STEP CONFIRM: call once with `confirm:false` (default) to get ' +
966
+ 'a DRY RUN — the response lists every entity still referencing this version (defects, suites, ' +
967
+ 'exec plans, scenarios, schedulers, pipelines, workflows for a TC; TCs, other FNs, workflows ' +
968
+ 'for a FN). Only when EVERY dependency map is empty is the delete allowed; the server itself ' +
969
+ "REFUSES a `confirm:true` call when refs still exist, so `confirm:true` doesn't guarantee " +
970
+ 'deletion — the tool inspects the response and reports honestly. Refuses to delete the LAST ' +
971
+ 'remaining version (the parent would be orphaned; use `tc_delete` / `delete_function` instead ' +
972
+ 'to remove the whole entity). SimplifyQA-only. Requires a live connection.',
973
+ inputSchema: {
974
+ kind: z.enum(['tc', 'fn']).describe("'tc' for a test-case version; 'fn' for a function version."),
975
+ parentId: z.string().describe('Parent TC or FN id (numeric or business key like TC_123 / FN_45).'),
976
+ versionId: z.string().describe("The version's uuid (from tc_get.versions[].id or get_function.versions[].id)."),
977
+ versionName: z.string().describe("The version's human name (e.g. 'v1.0'). Required by the backend validator."),
978
+ confirm: z.boolean().optional().describe('False (default) → dry-run; true → apply delete (server still refuses if any dependency exists).'),
979
+ },
980
+ annotations: { destructiveHint: true },
981
+ }, async ({ kind, parentId, versionId, versionName, confirm }) => {
982
+ const adapter = deps.getAdapter();
983
+ if (!adapter)
984
+ return err(NOT_CONNECTED);
985
+ if (!adapter.deleteVersion)
986
+ return err('This product has no per-version delete support.');
987
+ // Client-side "last version" guard — mirrors the SQA UI (testcase.component.ts:2126,
988
+ // function.component.ts:1600). Backend cheerfully deletes the sole remaining version and
989
+ // orphans the parent, so we block it here.
990
+ try {
991
+ const parent = kind === 'tc'
992
+ ? await adapter.getTestCase(parentId)
993
+ : adapter.getFunction ? await adapter.getFunction(parentId) : null;
994
+ const versions = parent?.versions ?? [];
995
+ if (versions.length === 0) {
996
+ return err(`${kind === 'tc' ? 'Test case' : 'Function'} ${parentId} has no versions to delete.`);
997
+ }
998
+ if (versions.length === 1) {
999
+ return err(`${kind === 'tc' ? 'Test case' : 'Function'} ${parentId} has only one version — ` +
1000
+ 'deleting it would orphan the parent (all steps and testdata go with it). ' +
1001
+ `Delete the whole ${kind === 'tc' ? 'test case (tc_delete)' : 'function (delete_function)'} ` +
1002
+ 'instead, or add another version first.');
1003
+ }
1004
+ if (!versions.some((v) => v.id === versionId)) {
1005
+ const listing = versions.map((v) => ` - ${v.name} \`${v.id}\``).join('\n');
1006
+ return err(`Version \`${versionId}\` not found on ${parentId}. Known versions:\n${listing}`);
1007
+ }
1008
+ }
1009
+ catch (e) {
1010
+ return err(describeError(e));
1011
+ }
1012
+ try {
1013
+ const result = await adapter.deleteVersion({
1014
+ kind,
1015
+ parentId,
1016
+ versionId,
1017
+ versionName,
1018
+ ...(confirm !== undefined ? { confirm } : {}),
1019
+ });
1020
+ if (result.dryRun) {
1021
+ if (!result.hasBlockers) {
1022
+ return ok(`Dry-run: version \`${versionId}\` (${versionName}) has NO dependencies — safe to delete. ` +
1023
+ 'Call again with confirm:true to apply.', { result });
1024
+ }
1025
+ const rows = result.dependencies.map((d) => renderVersionDepRow(d));
1026
+ return ok(`Dry-run: version \`${versionId}\` (${versionName}) still has references. ` +
1027
+ 'Deleting is blocked until the referring rows are moved or removed:\n\n' +
1028
+ rows.join('\n'), { result });
1029
+ }
1030
+ if (result.deleted) {
1031
+ return ok(`Deleted version \`${versionId}\` (${versionName}). Cascade: steps, testdata and ` +
1032
+ 'testdata iterations for this version were soft-deleted along with it.', { result });
1033
+ }
1034
+ // confirm:true but server returned deps → refused.
1035
+ const rows = result.dependencies.map((d) => renderVersionDepRow(d));
1036
+ return err(`Server REFUSED to delete version \`${versionId}\` — it is still referenced. ` +
1037
+ 'Remove or move the refs, then retry:\n\n' + rows.join('\n'));
272
1038
  }
273
1039
  catch (e) {
274
1040
  return err(describeError(e));
@@ -276,9 +1042,10 @@ export function registerDiscoveryTools(server, deps) {
276
1042
  });
277
1043
  server.registerTool('suite_list', {
278
1044
  title: 'List test suites',
279
- description: 'List the test suites in the connected product — id, name, and member (test-case) count. Use ' +
280
- 'suite_get to read one with its ordered members, or suite_run to execute one. Requires a live ' +
281
- 'connection (qa_connect). Read-only and product-neutral.',
1045
+ description: 'List the test suites in the connected product — id, name, type (Manual/Automation, where the ' +
1046
+ 'provider has a suite-level type — SimplifyQA does, Walnut doesn\'t), and member (test-case) ' +
1047
+ 'count. Use suite_get to read one with its ordered members, or suite_run to execute one. ' +
1048
+ 'Requires a live connection (qa_connect). Read-only and product-neutral.',
282
1049
  inputSchema: {},
283
1050
  annotations: { readOnlyHint: true },
284
1051
  }, async () => {
@@ -288,11 +1055,49 @@ export function registerDiscoveryTools(server, deps) {
288
1055
  try {
289
1056
  const rows = await adapter.listSuites();
290
1057
  if (!rows.length)
291
- return ok('No test suites found.', { suites: [] });
292
- const text = `${rows.length} suite${rows.length === 1 ? '' : 's'}:\n\n` +
293
- '| id | name | test cases |\n|---|---|---|\n' +
294
- rows.map((s) => `| ${escCell(s.id)} | ${escCell(s.name)} | ${s.memberCount ?? '-'} |`).join('\n');
295
- return ok(text, { suites: rows });
1058
+ return ok('No test suites found.', { suites: [], totalCount: totalOf(rows) ?? 0 });
1059
+ const text = `${countHeader(rows, 'suite')}:\n\n` +
1060
+ '| id | name | type | test cases |\n|---|---|---|---|\n' +
1061
+ rows.map((s) => `| ${escCell(s.id)} | ${escCell(s.name)} | ${escCell(s.suiteType ?? '-')} | ${s.memberCount ?? '-'} |`).join('\n');
1062
+ return ok(text, { suites: rows, totalCount: totalOf(rows) ?? rows.length });
1063
+ }
1064
+ catch (e) {
1065
+ return err(describeError(e));
1066
+ }
1067
+ });
1068
+ server.registerTool('list_users', {
1069
+ title: 'List users (resolve a name to a userId)',
1070
+ description: 'List users the caller can see in the connected product — id, name, email, username, status. ' +
1071
+ 'Use this to resolve a person\'s NAME to a userId before filtering tc_list / story_list by ' +
1072
+ '`createdBy` / `updatedBy`. Non-admin callers are auto-scoped server-side to users in their ' +
1073
+ 'assigned projects. Optional `search` matches on name (case-insensitive substring). Optional ' +
1074
+ '`projectId` narrows further to that project\'s members. Requires a live connection. Read-only.',
1075
+ inputSchema: {
1076
+ search: z.string().optional().describe('Case-insensitive substring over user name.'),
1077
+ projectId: z.string().optional().describe('Only users assigned to this project (from list_projects).'),
1078
+ limit: z.number().int().positive().optional().describe('Max results (default 100).'),
1079
+ offset: z.number().int().nonnegative().optional().describe('Result offset (paging).'),
1080
+ },
1081
+ annotations: { readOnlyHint: true },
1082
+ }, async ({ search, projectId, limit, offset }) => {
1083
+ const adapter = deps.getAdapter();
1084
+ if (!adapter)
1085
+ return err(NOT_CONNECTED);
1086
+ if (!adapter.listUsers)
1087
+ return err('This product does not expose a user directory.');
1088
+ try {
1089
+ const rows = await adapter.listUsers({
1090
+ ...(search ? { search } : {}),
1091
+ ...(projectId ? { projectId } : {}),
1092
+ ...(limit != null ? { limit } : {}),
1093
+ ...(offset != null ? { offset } : {}),
1094
+ });
1095
+ if (!rows.length)
1096
+ return ok('No users found.', { users: [], totalCount: totalOf(rows) ?? 0 });
1097
+ const text = `${countHeader(rows, 'user')}:\n\n` +
1098
+ '| id | name | email | username | status |\n|---|---|---|---|---|\n' +
1099
+ rows.map((u) => `| ${escCell(u.id)} | ${escCell(u.name ?? '-')} | ${escCell(u.email ?? '-')} | ${escCell(u.username ?? '-')} | ${escCell(u.status ?? '-')} |`).join('\n');
1100
+ return ok(text, { users: rows, totalCount: totalOf(rows) ?? rows.length });
296
1101
  }
297
1102
  catch (e) {
298
1103
  return err(describeError(e));
@@ -352,7 +1157,18 @@ export function registerDiscoveryTools(server, deps) {
352
1157
  const ops = tcIds.map((tcId) => ({ op: 'add', tcId }));
353
1158
  const suite = await adapter.updateSuiteMembers(suiteId, ops);
354
1159
  const n = suite.members.length;
355
- return ok(`Added ${tcIds.length} test case${tcIds.length === 1 ? '' : 's'} to **${suite.name}** — now ${n} member${n === 1 ? '' : 's'}.` +
1160
+ // Resolve titles for the confirmation — membership rows only carry `tcId`.
1161
+ const added = await Promise.all(tcIds.map(async (tcId) => {
1162
+ try {
1163
+ const tc = await adapter.getTestCase(tcId);
1164
+ return tc.title ? `${tc.title} (${tcId})` : tcId;
1165
+ }
1166
+ catch {
1167
+ return tcId;
1168
+ }
1169
+ }));
1170
+ return ok(`Added ${added.length} test case${added.length === 1 ? '' : 's'} to **${suite.name}**: ` +
1171
+ `${added.join(', ')} — now ${n} member${n === 1 ? '' : 's'}.` +
356
1172
  membersTable(suite), { suite });
357
1173
  }
358
1174
  catch (e) {