common-qa-mcp 0.2.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 (110) hide show
  1. package/README.md +6 -0
  2. package/dist/adapters/factory.d.ts +17 -7
  3. package/dist/adapters/factory.d.ts.map +1 -1
  4. package/dist/adapters/factory.js +44 -7
  5. package/dist/adapters/factory.js.map +1 -1
  6. package/dist/adapters/http.d.ts +50 -0
  7. package/dist/adapters/http.d.ts.map +1 -0
  8. package/dist/adapters/http.js +108 -0
  9. package/dist/adapters/http.js.map +1 -0
  10. package/dist/adapters/remoteMcp/adapter.d.ts +16 -2
  11. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  12. package/dist/adapters/remoteMcp/adapter.js +34 -5
  13. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  14. package/dist/adapters/simplifyqa/adapter.d.ts +437 -0
  15. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -0
  16. package/dist/adapters/simplifyqa/adapter.js +3097 -0
  17. package/dist/adapters/simplifyqa/adapter.js.map +1 -0
  18. package/dist/adapters/simplifyqa/mapper.d.ts +95 -0
  19. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -0
  20. package/dist/adapters/simplifyqa/mapper.js +858 -0
  21. package/dist/adapters/simplifyqa/mapper.js.map +1 -0
  22. package/dist/adapters/types.d.ts +168 -5
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/adapters/walnut/adapter.d.ts +235 -0
  25. package/dist/adapters/walnut/adapter.d.ts.map +1 -0
  26. package/dist/adapters/walnut/adapter.js +1114 -0
  27. package/dist/adapters/walnut/adapter.js.map +1 -0
  28. package/dist/adapters/walnut/mapper.d.ts +30 -9
  29. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  30. package/dist/adapters/walnut/mapper.js +149 -24
  31. package/dist/adapters/walnut/mapper.js.map +1 -1
  32. package/dist/config.d.ts +38 -14
  33. package/dist/config.d.ts.map +1 -1
  34. package/dist/config.js +73 -19
  35. package/dist/config.js.map +1 -1
  36. package/dist/connection.d.ts +3 -2
  37. package/dist/connection.d.ts.map +1 -1
  38. package/dist/connection.js +44 -15
  39. package/dist/connection.js.map +1 -1
  40. package/dist/domain/types.d.ts +680 -5
  41. package/dist/domain/types.d.ts.map +1 -1
  42. package/dist/drafts/store.d.ts +4 -1
  43. package/dist/drafts/store.d.ts.map +1 -1
  44. package/dist/drafts/store.js +7 -2
  45. package/dist/drafts/store.js.map +1 -1
  46. package/dist/initAgents.d.ts.map +1 -1
  47. package/dist/initAgents.js +48 -18
  48. package/dist/initAgents.js.map +1 -1
  49. package/dist/requestConnection.d.ts +5 -3
  50. package/dist/requestConnection.d.ts.map +1 -1
  51. package/dist/requestConnection.js +11 -5
  52. package/dist/requestConnection.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +47 -1
  55. package/dist/server.js.map +1 -1
  56. package/dist/tools/backlog.tools.d.ts +13 -0
  57. package/dist/tools/backlog.tools.d.ts.map +1 -0
  58. package/dist/tools/backlog.tools.js +399 -0
  59. package/dist/tools/backlog.tools.js.map +1 -0
  60. package/dist/tools/connection.tools.js +5 -5
  61. package/dist/tools/connection.tools.js.map +1 -1
  62. package/dist/tools/defects.tools.d.ts +7 -0
  63. package/dist/tools/defects.tools.d.ts.map +1 -0
  64. package/dist/tools/defects.tools.js +82 -0
  65. package/dist/tools/defects.tools.js.map +1 -0
  66. package/dist/tools/discovery.tools.d.ts.map +1 -1
  67. package/dist/tools/discovery.tools.js +951 -43
  68. package/dist/tools/discovery.tools.js.map +1 -1
  69. package/dist/tools/formulas.tools.d.ts +7 -0
  70. package/dist/tools/formulas.tools.d.ts.map +1 -0
  71. package/dist/tools/formulas.tools.js +56 -0
  72. package/dist/tools/formulas.tools.js.map +1 -0
  73. package/dist/tools/heal.tools.d.ts +17 -0
  74. package/dist/tools/heal.tools.d.ts.map +1 -0
  75. package/dist/tools/heal.tools.js +76 -0
  76. package/dist/tools/heal.tools.js.map +1 -0
  77. package/dist/tools/markdown.d.ts +3 -3
  78. package/dist/tools/markdown.d.ts.map +1 -1
  79. package/dist/tools/markdown.js +89 -18
  80. package/dist/tools/markdown.js.map +1 -1
  81. package/dist/tools/objects.tools.d.ts +7 -0
  82. package/dist/tools/objects.tools.d.ts.map +1 -0
  83. package/dist/tools/objects.tools.js +201 -0
  84. package/dist/tools/objects.tools.js.map +1 -0
  85. package/dist/tools/recording.tools.d.ts.map +1 -1
  86. package/dist/tools/recording.tools.js +92 -12
  87. package/dist/tools/recording.tools.js.map +1 -1
  88. package/dist/tools/run.tools.d.ts.map +1 -1
  89. package/dist/tools/run.tools.js +464 -39
  90. package/dist/tools/run.tools.js.map +1 -1
  91. package/dist/tools/schemas.d.ts +21 -4
  92. package/dist/tools/schemas.d.ts.map +1 -1
  93. package/dist/tools/schemas.js +14 -1
  94. package/dist/tools/schemas.js.map +1 -1
  95. package/dist/tools/storySync.tools.d.ts +66 -0
  96. package/dist/tools/storySync.tools.d.ts.map +1 -0
  97. package/dist/tools/storySync.tools.js +213 -0
  98. package/dist/tools/storySync.tools.js.map +1 -0
  99. package/dist/tools/tasks.tools.d.ts +7 -0
  100. package/dist/tools/tasks.tools.d.ts.map +1 -0
  101. package/dist/tools/tasks.tools.js +75 -0
  102. package/dist/tools/tasks.tools.js.map +1 -0
  103. package/dist/tools/testcase.tools.d.ts.map +1 -1
  104. package/dist/tools/testcase.tools.js +162 -101
  105. package/dist/tools/testcase.tools.js.map +1 -1
  106. package/dist/tools/testdata.tools.d.ts +7 -0
  107. package/dist/tools/testdata.tools.d.ts.map +1 -0
  108. package/dist/tools/testdata.tools.js +249 -0
  109. package/dist/tools/testdata.tools.js.map +1 -0
  110. 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));
@@ -129,9 +437,15 @@ export function registerDiscoveryTools(server, deps) {
129
437
  status: z.string().optional(),
130
438
  limit: z.number().int().positive().optional(),
131
439
  offset: z.number().int().nonnegative().optional(),
440
+ sprintId: z.string().optional().describe('Only stories that belong to this sprint (from list_sprints).'),
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`.'),
132
446
  },
133
447
  annotations: { readOnlyHint: true },
134
- }, async ({ search, status, limit, offset }) => {
448
+ }, async ({ search, status, limit, offset, sprintId, releaseId, createdBy, updatedBy, ownerId, filters }) => {
135
449
  const adapter = deps.getAdapter();
136
450
  if (!adapter)
137
451
  return err(NOT_CONNECTED);
@@ -143,13 +457,20 @@ export function registerDiscoveryTools(server, deps) {
143
457
  ...(status ? { status } : {}),
144
458
  ...(limit != null ? { limit } : {}),
145
459
  ...(offset != null ? { offset } : {}),
460
+ ...(sprintId ? { sprintId } : {}),
461
+ ...(releaseId ? { releaseId } : {}),
462
+ ...(createdBy ? { createdBy } : {}),
463
+ ...(updatedBy ? { updatedBy } : {}),
464
+ ...(ownerId ? { ownerId } : {}),
465
+ ...(filters?.length ? { filters } : {}),
146
466
  });
147
467
  if (!rows.length)
148
- return ok('No user stories found.', { stories: [] });
149
- 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` +
150
471
  '| key | title | status |\n|---|---|---|\n' +
151
472
  rows.map((s) => `| ${escCell(s.key ?? s.id)} | ${escCell(s.title)} | ${escCell(s.status ?? '-')} |`).join('\n');
152
- return ok(text, { stories: rows });
473
+ return ok(text, { stories: view, totalCount: totalOf(rows) ?? rows.length });
153
474
  }
154
475
  catch (e) {
155
476
  return err(describeError(e));
@@ -182,11 +503,549 @@ export function registerDiscoveryTools(server, deps) {
182
503
  return err(describeError(e));
183
504
  }
184
505
  });
506
+ server.registerTool('list_releases', {
507
+ title: 'List releases (project-scoped or all)',
508
+ description: 'List RELEASES in SimplifyQA — id, name, state, planned dates. `scope` is required: ' +
509
+ '"project" = releases attached to the currently connected project, "all" = every release the ' +
510
+ 'token can see. ASK the user which scope before calling — do not guess. Requires a live ' +
511
+ 'connection (qa_connect). Read-only.',
512
+ inputSchema: {
513
+ scope: z.enum(['project', 'all']).describe('ASK the user first: "project" = current project only, "all" = every release visible to the token.'),
514
+ },
515
+ annotations: { readOnlyHint: true },
516
+ }, async ({ scope }) => {
517
+ const adapter = deps.getAdapter();
518
+ if (!adapter)
519
+ return err(NOT_CONNECTED);
520
+ if (!adapter.listReleases)
521
+ return err('This product has no release/sprint model.');
522
+ try {
523
+ const rows = await adapter.listReleases({ scope });
524
+ if (!rows.length)
525
+ return ok(`No releases found (scope: ${scope}).`, { releases: [], totalCount: totalOf(rows) ?? 0 });
526
+ const text = `${countHeader(rows, 'release')} (scope: ${scope}):\n\n` +
527
+ '| id | name | state | planned start | planned end |\n|---|---|---|---|---|\n' +
528
+ rows
529
+ .map((r) => `| ${escCell(r.id)} | ${escCell(r.name)} | ${escCell(r.state ?? '-')} | ${escCell(r.plannedStartDate ?? '-')} | ${escCell(r.plannedEndDate ?? '-')} |`)
530
+ .join('\n');
531
+ return ok(text, { releases: rows, totalCount: totalOf(rows) ?? rows.length });
532
+ }
533
+ catch (e) {
534
+ return err(describeError(e));
535
+ }
536
+ });
537
+ server.registerTool('list_sprints', {
538
+ title: 'List sprints (optionally within a release)',
539
+ description: 'List SPRINTS in SimplifyQA — id, name, parent release, state, planned dates. Pass ' +
540
+ '`releaseId` (from list_releases) to see sprints of one release; omit to see every sprint the ' +
541
+ 'token can see. To answer "how many stories in this sprint", follow up with ' +
542
+ 'story_list({ sprintId }). Requires a live connection. Read-only.',
543
+ inputSchema: {
544
+ releaseId: z.string().optional().describe('Only sprints belonging to this release (from list_releases).'),
545
+ },
546
+ annotations: { readOnlyHint: true },
547
+ }, async ({ releaseId }) => {
548
+ const adapter = deps.getAdapter();
549
+ if (!adapter)
550
+ return err(NOT_CONNECTED);
551
+ if (!adapter.listSprints)
552
+ return err('This product has no release/sprint model.');
553
+ try {
554
+ const rows = await adapter.listSprints(releaseId !== undefined ? { releaseId } : {});
555
+ if (!rows.length)
556
+ return ok('No sprints found.', { sprints: [], totalCount: totalOf(rows) ?? 0 });
557
+ const text = `${countHeader(rows, 'sprint')}${releaseId ? ` (release ${releaseId})` : ''}:\n\n` +
558
+ '| id | name | release | state | planned start | planned end |\n|---|---|---|---|---|---|\n' +
559
+ rows
560
+ .map((s) => `| ${escCell(s.id)} | ${escCell(s.name)} | ${escCell(s.releaseId ?? '-')} | ${escCell(s.state ?? '-')} | ${escCell(s.plannedStartDate ?? '-')} | ${escCell(s.plannedEndDate ?? '-')} |`)
561
+ .join('\n');
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 });
816
+ }
817
+ catch (e) {
818
+ return err(describeError(e));
819
+ }
820
+ });
821
+ server.registerTool('list_modules', {
822
+ title: 'List modules / folders (SimplifyQA test-case tree)',
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.',
827
+ inputSchema: {},
828
+ annotations: { readOnlyHint: true },
829
+ }, async () => {
830
+ const adapter = deps.getAdapter();
831
+ if (!adapter)
832
+ return err(NOT_CONNECTED);
833
+ if (!adapter.listModules)
834
+ return err('This product has no module tree.');
835
+ try {
836
+ const rows = await adapter.listModules();
837
+ if (!rows.length)
838
+ return ok('No modules found.', { modules: [], totalCount: totalOf(rows) ?? 0 });
839
+ const text = `${countHeader(rows, 'module')}:\n\n` +
840
+ '| id | name | parent |\n|---|---|---|\n' +
841
+ rows.map((m) => `| ${escCell(m.id)} | ${escCell(m.name)} | ${escCell(m.parentId ?? '-')} |`).join('\n');
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'));
1038
+ }
1039
+ catch (e) {
1040
+ return err(describeError(e));
1041
+ }
1042
+ });
185
1043
  server.registerTool('suite_list', {
186
1044
  title: 'List test suites',
187
- description: 'List the test suites in the connected product — id, name, and member (test-case) count. Use ' +
188
- 'suite_get to read one with its ordered members, or suite_run to execute one. Requires a live ' +
189
- '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.',
190
1049
  inputSchema: {},
191
1050
  annotations: { readOnlyHint: true },
192
1051
  }, async () => {
@@ -196,11 +1055,49 @@ export function registerDiscoveryTools(server, deps) {
196
1055
  try {
197
1056
  const rows = await adapter.listSuites();
198
1057
  if (!rows.length)
199
- return ok('No test suites found.', { suites: [] });
200
- const text = `${rows.length} suite${rows.length === 1 ? '' : 's'}:\n\n` +
201
- '| id | name | test cases |\n|---|---|---|\n' +
202
- rows.map((s) => `| ${escCell(s.id)} | ${escCell(s.name)} | ${s.memberCount ?? '-'} |`).join('\n');
203
- 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 });
204
1101
  }
205
1102
  catch (e) {
206
1103
  return err(describeError(e));
@@ -260,7 +1157,18 @@ export function registerDiscoveryTools(server, deps) {
260
1157
  const ops = tcIds.map((tcId) => ({ op: 'add', tcId }));
261
1158
  const suite = await adapter.updateSuiteMembers(suiteId, ops);
262
1159
  const n = suite.members.length;
263
- 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'}.` +
264
1172
  membersTable(suite), { suite });
265
1173
  }
266
1174
  catch (e) {