common-qa-mcp 0.5.0 โ†’ 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +37 -79
  2. package/dist/adapters/remoteMcp/adapter.d.ts +7 -1
  3. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  4. package/dist/adapters/remoteMcp/adapter.js +8 -3
  5. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  6. package/dist/adapters/simplifyqa/adapter.d.ts +112 -26
  7. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  8. package/dist/adapters/simplifyqa/adapter.js +731 -240
  9. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  10. package/dist/adapters/simplifyqa/mapper.d.ts +5 -1
  11. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  12. package/dist/adapters/simplifyqa/mapper.js +45 -1
  13. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  14. package/dist/adapters/types.d.ts +55 -13
  15. package/dist/adapters/types.d.ts.map +1 -1
  16. package/dist/adapters/walnut/actionTypes.d.ts +1 -1
  17. package/dist/adapters/walnut/actionTypes.d.ts.map +1 -1
  18. package/dist/adapters/walnut/actionTypes.js +37 -0
  19. package/dist/adapters/walnut/actionTypes.js.map +1 -1
  20. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  21. package/dist/adapters/walnut/mapper.js +12 -1
  22. package/dist/adapters/walnut/mapper.js.map +1 -1
  23. package/dist/domain/types.d.ts +105 -15
  24. package/dist/domain/types.d.ts.map +1 -1
  25. package/dist/initAgents.d.ts.map +1 -1
  26. package/dist/initAgents.js +132 -26
  27. package/dist/initAgents.js.map +1 -1
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +95 -1
  30. package/dist/server.js.map +1 -1
  31. package/dist/tools/backlog.tools.d.ts.map +1 -1
  32. package/dist/tools/backlog.tools.js +24 -2
  33. package/dist/tools/backlog.tools.js.map +1 -1
  34. package/dist/tools/connection.tools.d.ts.map +1 -1
  35. package/dist/tools/connection.tools.js +15 -3
  36. package/dist/tools/connection.tools.js.map +1 -1
  37. package/dist/tools/defects.tools.d.ts.map +1 -1
  38. package/dist/tools/defects.tools.js +29 -3
  39. package/dist/tools/defects.tools.js.map +1 -1
  40. package/dist/tools/discovery.tools.d.ts.map +1 -1
  41. package/dist/tools/discovery.tools.js +304 -137
  42. package/dist/tools/discovery.tools.js.map +1 -1
  43. package/dist/tools/gapAnalysis.tools.d.ts +31 -0
  44. package/dist/tools/gapAnalysis.tools.d.ts.map +1 -0
  45. package/dist/tools/gapAnalysis.tools.js +287 -0
  46. package/dist/tools/gapAnalysis.tools.js.map +1 -0
  47. package/dist/tools/impactAnalysis.tools.d.ts +17 -0
  48. package/dist/tools/impactAnalysis.tools.d.ts.map +1 -0
  49. package/dist/tools/impactAnalysis.tools.js +352 -0
  50. package/dist/tools/impactAnalysis.tools.js.map +1 -0
  51. package/dist/tools/markdown.d.ts.map +1 -1
  52. package/dist/tools/markdown.js +9 -5
  53. package/dist/tools/markdown.js.map +1 -1
  54. package/dist/tools/objects.tools.d.ts.map +1 -1
  55. package/dist/tools/objects.tools.js +174 -18
  56. package/dist/tools/objects.tools.js.map +1 -1
  57. package/dist/tools/recording.tools.d.ts.map +1 -1
  58. package/dist/tools/recording.tools.js +108 -10
  59. package/dist/tools/recording.tools.js.map +1 -1
  60. package/dist/tools/run.tools.d.ts.map +1 -1
  61. package/dist/tools/run.tools.js +257 -63
  62. package/dist/tools/run.tools.js.map +1 -1
  63. package/dist/tools/tasks.tools.d.ts.map +1 -1
  64. package/dist/tools/tasks.tools.js +29 -3
  65. package/dist/tools/tasks.tools.js.map +1 -1
  66. package/dist/tools/testcase.tools.d.ts.map +1 -1
  67. package/dist/tools/testcase.tools.js +14 -5
  68. package/dist/tools/testcase.tools.js.map +1 -1
  69. package/package.json +1 -1
@@ -14,6 +14,14 @@ function localAgentLine(a) {
14
14
  const host = a.host && a.host !== a.name ? ` ยท ${a.host}` : '';
15
15
  return `- ${a.online ? '๐ŸŸข' : 'โšช'} ${a.name ?? a.id}${host} \`${a.id}\`${a.status ? ` (${a.status})` : ''}`;
16
16
  }
17
+ function mobileDeviceLine(d) {
18
+ // `active` here is the last-known connection flag from the SQA DB (stale by design). Show it as
19
+ // a hint, not a gate โ€” a device the user just plugged in may still read `active:false` here.
20
+ const dot = d.active ? '๐ŸŸข' : 'โšช';
21
+ const os = d.osVersion ? ` ยท ${d.platform.toLowerCase()} ${d.osVersion}` : ` ยท ${d.platform.toLowerCase()}`;
22
+ const status = d.status ? ` (${d.status})` : '';
23
+ return `- ${dot} ${d.name}${os} \`${d.id}\`${status}`;
24
+ }
17
25
  /** One line per schedule for management listings โ€” what it runs, cadence, where + pinned agent,
18
26
  * browser, next run, status; the credential-free id in backticks is what the management tools take. */
19
27
  function scheduleLine(s) {
@@ -88,8 +96,12 @@ function statusText(s) {
88
96
  return parts.length ? ` ยท ${parts.join(', ')}` : '';
89
97
  })()
90
98
  : '';
91
- const kids = s.children?.length
92
- ? '\n\n' + s.children.map((c) => `- ${icon(c.state)} ${c.id}${c.title ? ` โ€” ${c.title}` : ''} (${c.state})`).join('\n')
99
+ const CHILD_CAP = 100;
100
+ const total = s.children?.length ?? 0;
101
+ const shown = s.children?.slice(0, CHILD_CAP) ?? [];
102
+ const kids = shown.length
103
+ ? '\n\n' + shown.map((c) => `- ${icon(c.state)} ${c.id}${c.title ? ` โ€” ${c.title}` : ''} (${c.state})`).join('\n') +
104
+ (total > CHILD_CAP ? `\nโ€ฆ and ${total - CHILD_CAP} more (${total} total)` : '')
93
105
  : '';
94
106
  return `Run ${s.runId} ยท **${s.state}**${prog}${tally}${link}${kids}`;
95
107
  }
@@ -120,13 +132,12 @@ export function registerRunTools(server, deps) {
120
132
  }
121
133
  });
122
134
  server.registerTool('local_agents', {
123
- title: 'List local agents (and which are online)',
124
- description: 'List the LOCAL agents registered to the connected customer and which are online. Use this ' +
125
- 'to show the user their local run options, or to pick a specific agent id (its credential_id) to ' +
126
- 'pass as `agentId` to a LOCAL tc_run / suite_run. Requires a live connection. ' +
127
- 'NOTE on online status: agents whose `machineName` matches this MCP host are reported online ' +
128
- '(mirrors sqa-ui\'s own-machine override โ€” the DB `active` flag is only refreshed at first ' +
129
- 'registration + a stale cron, so it lags behind). Other agents\' status still comes from the DB.',
135
+ title: "List THIS machine's local agent(s)",
136
+ description: "List the LOCAL agent(s) registered from THIS machine โ€” the desktop agent running on the box " +
137
+ "this MCP is on. Use for a LOCAL tc_run / suite_run. There is exactly one desktop agent per " +
138
+ "machine, so this is 0 or 1 row: empty means no agent is registered from this box (start the " +
139
+ "SimplifyQA desktop agent, or use mode:'remote' to dispatch to a teammate's). Use `remote_agents` " +
140
+ "for the other registered machines. Requires a live connection.",
130
141
  inputSchema: {},
131
142
  annotations: { readOnlyHint: true },
132
143
  }, async () => {
@@ -136,11 +147,43 @@ export function registerRunTools(server, deps) {
136
147
  if (!adapter.listLocalAgents)
137
148
  return err('This product has no local agents to list.');
138
149
  try {
139
- const agents = await adapter.listLocalAgents();
150
+ const all = await adapter.listLocalAgents();
151
+ const agents = all.filter((a) => a.isThisMachine);
152
+ if (!agents.length) {
153
+ return ok("No local agent is registered from this machine. Start the SimplifyQA desktop agent on " +
154
+ "this box, or use remote_agents + mode:'remote' to run on a teammate's machine.", { agents: [] });
155
+ }
140
156
  const online = agents.filter((a) => a.online);
157
+ const text = `${agents.length} local agent${agents.length === 1 ? '' : 's'} on this machine ยท ${online.length} online:\n\n` +
158
+ agents.map(localAgentLine).join('\n');
159
+ return ok(text, { agents, online: online.length });
160
+ }
161
+ catch (e) {
162
+ return err(describeError(e));
163
+ }
164
+ });
165
+ server.registerTool('remote_agents', {
166
+ title: 'List REMOTE agents (other registered machines)',
167
+ description: "List the LOCAL agents registered on OTHER machines (teammates' boxes) โ€” the pool a REMOTE " +
168
+ "run dispatches to. Use to pick an `agentId` for a REMOTE tc_run / suite_run. Excludes this " +
169
+ "machine's own agent (that's `local_agents`). REMOTE dispatches to a specific teammate's " +
170
+ "registered desktop agent through the SimplifyQA queue; the picked agent's browsers / adb " +
171
+ "devices are what actually execute. Requires a live connection.",
172
+ inputSchema: {},
173
+ annotations: { readOnlyHint: true },
174
+ }, async () => {
175
+ const adapter = deps.getAdapter();
176
+ if (!adapter)
177
+ return err(NOT_CONNECTED);
178
+ if (!adapter.listLocalAgents)
179
+ return err('This product has no local agents to list.');
180
+ try {
181
+ const all = await adapter.listLocalAgents();
182
+ const agents = all.filter((a) => !a.isThisMachine);
141
183
  if (!agents.length)
142
- return ok('No local agents are registered to this project.', { agents: [] });
143
- const text = `${agents.length} local agent${agents.length === 1 ? '' : 's'} ยท ${online.length} online:\n\n` +
184
+ return ok('No other machines are registered as agents for this customer.', { agents: [] });
185
+ const online = agents.filter((a) => a.online);
186
+ const text = `${agents.length} remote agent${agents.length === 1 ? '' : 's'} ยท ${online.length} online:\n\n` +
144
187
  agents.map(localAgentLine).join('\n');
145
188
  return ok(text, { agents, online: online.length });
146
189
  }
@@ -148,24 +191,81 @@ export function registerRunTools(server, deps) {
148
191
  return err(describeError(e));
149
192
  }
150
193
  });
194
+ server.registerTool('mobile_list_devices', {
195
+ title: 'List mobile devices (Android or iOS)',
196
+ description: "List MOBILE devices available to the project โ€” union of the LOCAL roster (real devices + " +
197
+ "emulators/simulators registered via the desktop agent, from `/tm/mobileDevices/search`) " +
198
+ "and the CLOUD roster (whichever farm the project has integrated: LambdaTest / " +
199
+ "BrowserStack / SauceLabs / SimplifyQA Device Farm โ€” the first non-empty one wins, " +
200
+ "matching the UI's single-provider-per-project model). Use before `tc_run` for an ANDROID " +
201
+ "or IOS test case to pick a `deviceId` (and read `deviceType` + `cloudDeviceType` off the " +
202
+ "same row). The `active` flag on local rows is last-known: stale by design (the SQA " +
203
+ "backend writes it when the desktop agent connects/disconnects, and a 7s-stale cron flips " +
204
+ "it back); a device the user just plugged in may still read `active:false` here. Cloud " +
205
+ "rows don't carry `active` โ€” cloud farms are always assumed reachable. Requires a live " +
206
+ "connection.",
207
+ inputSchema: {
208
+ platform: z.enum(['android', 'ios']).describe("Which mobile platform to list devices for."),
209
+ },
210
+ annotations: { readOnlyHint: true },
211
+ }, async ({ platform }) => {
212
+ const adapter = deps.getAdapter();
213
+ if (!adapter)
214
+ return err(NOT_CONNECTED);
215
+ if (!adapter.listMobileDevices)
216
+ return err('This product has no mobile device roster to list.');
217
+ try {
218
+ const devices = await adapter.listMobileDevices(platform.toUpperCase());
219
+ if (!devices.length) {
220
+ return ok(`No ${platform.toUpperCase()} devices are registered to this project. Connect a device ` +
221
+ `via the SimplifyQA desktop agent, then try again.`, { devices: [] });
222
+ }
223
+ // Group by deviceType so the client can present the same tabs the UI does (Real / Emulator
224
+ // / Simulator / Cloud Device). Cloud rows come from the configured farm (LT / BS / SL / SQA
225
+ // Device Farm); the row's `cloudDeviceType` names which one โ€” pass it back on `tc_run`.
226
+ const groups = {};
227
+ for (const d of devices) {
228
+ if (!groups[d.deviceType])
229
+ groups[d.deviceType] = [];
230
+ groups[d.deviceType].push(d);
231
+ }
232
+ const active = devices.filter((d) => d.active).length;
233
+ const sections = Object.entries(groups)
234
+ .map(([type, rows]) => `**${type}** (${rows.length})\n${rows.map(mobileDeviceLine).join('\n')}`)
235
+ .join('\n\n');
236
+ const text = `${devices.length} ${platform.toUpperCase()} device${devices.length === 1 ? '' : 's'} ยท ${active} active:\n\n` +
237
+ sections;
238
+ return ok(text, { devices, active });
239
+ }
240
+ catch (e) {
241
+ return err(describeError(e));
242
+ }
243
+ });
151
244
  server.registerTool('tc_run', {
152
- title: 'Run a test case (local or cloud; now or scheduled)',
245
+ title: 'Run a test case (local / remote / cloud; now or scheduled)',
153
246
  description: "Execute ONE existing test case in the connected product. ASK the user for EVERY field the " +
154
247
  "Execute dialog asks โ€” never assume โ€” and in ORDER: (1) `releaseId` (from list_releases; " +
155
248
  "pass '' if the user skips), (2) `sprintId` (from list_sprints; pass '' if the user skips), " +
156
249
  "(3) `versionId` (from tc_get's `versions[]`; pass '' to accept the TC's current version), " +
157
- '(4) `environment` (from tc_list_environments โ€” required), (5) `browser` (required), ' +
158
- '(6) `mode` local/cloud, (7) `when` immediate/schedule, (8) for a schedule the start time + ' +
159
- 'recurrence + timezone, (9) THEN offer the eligible agent/runner and pass the pick. The tool ' +
160
- 'REFUSES when any of (1)-(7) is omitted โ€” that is deliberate; it forces the ask instead of ' +
161
- 'silently defaulting. `mode` decides WHERE it runs:\n' +
162
- " โ€ข 'local' โ€” a local agent's own browser. Pass `agentId` from local_agents. For when:'immediate' only " +
163
- "ONLINE agents are eligible (a run-now can't target an offline agent; omit to auto-pick your own online " +
164
- "agent). For when:'schedule' ANY agent (online OR offline) is eligible and `agentId` is REQUIRED.\n" +
250
+ '(4) `environment` (from tc_list_environments โ€” required), (5) FOR WEB TCs: `browser` ' +
251
+ '(required); FOR ANDROID/IOS TCs: skip browser โ€” `deviceType` + `deviceId` are asked in step ' +
252
+ "(9) instead (call `mobile_list_devices` to enumerate), (6) `mode` local/remote/cloud, (7) " +
253
+ "`when` immediate/schedule, (8) for a schedule the start time + recurrence + timezone, (9) " +
254
+ 'THEN offer the eligible agent/runner and (for mobile) the device โ€” pass the picks. The tool ' +
255
+ 'REFUSES when any of (1)-(7) is omitted, and additionally when `deviceType`/`deviceId` are ' +
256
+ 'omitted on a mobile TC โ€” that is deliberate; it forces the ask instead of silently ' +
257
+ 'defaulting. `mode` decides WHERE it runs:\n' +
258
+ " โ€ข 'local' โ€” THIS machine's desktop agent (its own browser). Call `local_agents` (0 or 1 row per box). " +
259
+ "For when:'immediate' the tool first probes the desktop agent (:4040) and refuses cleanly if it's not up. " +
260
+ "Omit `agentId` to auto-pick this machine's agent (or pass it explicitly โ€” must be this machine's row). " +
261
+ "For when:'schedule' `agentId` is REQUIRED.\n" +
262
+ " โ€ข 'remote' โ€” ANOTHER registered machine's desktop agent (a teammate's box). Call `remote_agents` and " +
263
+ "OFFER the list; `agentId` is REQUIRED (no auto-pick across boxes). For when:'immediate' only ONLINE " +
264
+ "remote agents are eligible; for when:'schedule' ANY remote agent works (it runs when it fires).\n" +
165
265
  " โ€ข 'cloud' โ€” an online cloud runner VM (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
166
266
  'from cloud_runners; for a schedule omit it to let the backend auto-pick a live VM at fire time.\n' +
167
- 'IF THE USER HAS NOT SAID local or cloud, ASK โ€” this tool refuses when `mode` is omitted. For cloud, if ' +
168
- 'no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
267
+ 'IF THE USER HAS NOT SAID local, remote, or cloud, ASK โ€” this tool refuses when `mode` is omitted. For cloud, ' +
268
+ 'if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
169
269
  '`when` decides WHEN it runs (this tool REFUSES when `when` is omitted โ€” ASK the user):\n' +
170
270
  " โ€ข 'immediate' โ€” dispatch now; returns the run id โ€” poll run_status for the result.\n" +
171
271
  " โ€ข 'schedule' โ€” create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
@@ -173,8 +273,8 @@ export function registerRunTools(server, deps) {
173
273
  'NOTE: MANUAL test cases cannot be run or scheduled by an agent โ€” the backend rejects them.',
174
274
  inputSchema: {
175
275
  tcId: z.string().describe('The test case id (or business key) to run.'),
176
- mode: z.enum(['local', 'cloud']).optional().describe("Where to run. REQUIRED โ€” ASK the user if unstated (the tool refuses when omitted)."),
177
- agentId: z.string().optional().describe("For local: a specific agent credential_id (from local_agents). when:'immediate' โ†’ must be an ONLINE agent (or omit to auto-pick your own online agent); when:'schedule' โ†’ REQUIRED, and may be an offline agent (it runs when it fires)."),
276
+ mode: z.enum(['local', 'remote', 'cloud']).optional().describe("Where to run. REQUIRED โ€” ASK the user if unstated (the tool refuses when omitted)."),
277
+ agentId: z.string().optional().describe("For local: a specific agent credential_id from `local_agents` (must be THIS machine's row; omit to auto-pick). For remote: REQUIRED โ€” a credential_id from `remote_agents` (a teammate's row). when:'immediate' โ†’ the picked agent must be ONLINE; when:'schedule' โ†’ offline agents are OK (they run when they fire) and `agentId` is REQUIRED for both local and remote."),
178
278
  releaseId: z.string().optional().describe('Tag the run with a RELEASE (from list_releases). Optional in the UI; omit to leave untagged.'),
179
279
  sprintId: z.string().optional().describe('Tag the run with a SPRINT (from list_sprints). Optional in the UI; omit to leave untagged.'),
180
280
  versionId: z.string().optional().describe("Override the TC's currently-selected VERSION for this run (an id from the TC's `versions[]`). Omit to run the current version โ€” that's what the UI's Version chip defaults to."),
@@ -182,7 +282,10 @@ export function registerRunTools(server, deps) {
182
282
  browser: z.string().optional().describe("Browser, default 'chromium' (bundled + isolated). Others: 'chrome'/'msedge' (installed channel), 'firefox', 'webkit'. NOTE: a schedule accepts only chromium/msedge/firefox/webkit."),
183
283
  headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
184
284
  cloudRunnerId: z.string().optional().describe("For cloud: a specific ONLINE runner id (from cloud_runners). For when:'schedule' omit to auto-pick a live VM at fire time."),
185
- deviceId: z.string().optional().describe("REQUIRED for ANDROID/IOS test cases โ€” the target device id. LOCAL: adb serial or emulator name. CLOUD: the marketplace device name. Enumerate with android_list_devices."),
285
+ deviceId: z.string().optional().describe("REQUIRED for ANDROID/IOS test cases โ€” the target device id. LOCAL: adb serial or emulator name. CLOUD: the marketplace device name. Enumerate with `mobile_list_devices`."),
286
+ deviceType: z.enum(['real_device', 'emulator', 'simulator', 'cloud_device', 'cloud_emulator', 'cloud_simulator']).optional().describe("REQUIRED for ANDROID/IOS test cases โ€” the KIND of device the picked `deviceId` is (read off the matching row from `mobile_list_devices`). Drives the wire execution platform's `type` field."),
287
+ cloudDeviceType: z.enum(['lambda_test', 'browser_stack', 'sauce_labs', 'simplify_qa_device_form']).optional().describe("REQUIRED when `deviceType` is cloud_* โ€” which cloud farm the device came from (read off the matching row's `cloudDeviceType` from `mobile_list_devices`). Note SIMPLIFY_QA_DEVICE_FORM's legacy 'FORM' spelling โ€” mirrors the SQA wire schema exactly."),
288
+ osVersion: z.string().optional().describe("Optional OS version to stamp on the wire platform object for ANDROID/IOS โ€” read off the matching row from `mobile_list_devices`. Ignored for non-mobile tcTypes."),
186
289
  when: z.enum(['immediate', 'schedule']).optional().describe("When to run โ€” REQUIRED, ASK the user if unstated (the tool refuses when omitted). 'immediate' dispatches now; 'schedule' creates a schedule (needs scheduledFor)."),
187
290
  scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' โ€” ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
188
291
  scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
@@ -190,7 +293,7 @@ export function registerRunTools(server, deps) {
190
293
  timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
191
294
  },
192
295
  annotations: { readOnlyHint: false },
193
- }, async ({ tcId, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, when, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
296
+ }, async ({ tcId, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, deviceType, cloudDeviceType, osVersion, when, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
194
297
  const adapter = deps.getAdapter();
195
298
  if (!adapter)
196
299
  return err(NOT_CONNECTED);
@@ -205,25 +308,49 @@ export function registerRunTools(server, deps) {
205
308
  return err("ASK the user which SPRINT this run belongs to (from list_sprints), then call tc_run " +
206
309
  "again with sprintId. If the user wants no sprint, pass sprintId: '' (empty string).");
207
310
  }
311
+ // Pre-flight the TC once โ€” needed both to inline the version list (below) and to know the
312
+ // tcType (WEB vs ANDROID/IOS/โ€ฆ) so the refuse cascade asks the right platform-specific
313
+ // fields: browser for WEB, deviceType + deviceId for mobile. Mirrors tc_get's refusal at
314
+ // discovery.tools.ts:226; without this the model guesses a version NAME and the backend 500s.
315
+ let preview;
316
+ try {
317
+ preview = await adapter.getTestCase(tcId);
318
+ }
319
+ catch (e) {
320
+ return err(describeError(e));
321
+ }
322
+ const tcType = (preview.technologyType ?? 'WEB').toUpperCase();
323
+ const isMobile = tcType === 'ANDROID' || tcType === 'IOS';
208
324
  if (versionId === undefined) {
209
- return err("ASK the user which VERSION of the test case to run (from tc_get's `versions[]`), then " +
210
- "call tc_run again with versionId. To accept the TC's currently-selected version, " +
211
- "pass versionId: '' (empty string).");
325
+ const vs = preview.versions ?? [];
326
+ if (vs.length > 1) {
327
+ const listing = vs
328
+ .map((v) => ` โ€ข \`${v.id}\` โ€” **${v.name}**${v.isCurrent ? ' (current)' : ''}`)
329
+ .join('\n');
330
+ return err(`Test case ${preview.id ?? tcId} has ${vs.length} versions โ€” ASK the user which ` +
331
+ `one to run, then call tc_run again with versionId (pass the version id OR its ` +
332
+ `name โ€” either resolves). Pass versionId: '' to accept the TC's current ` +
333
+ `version.\n\n${listing}`);
334
+ }
335
+ versionId = '';
212
336
  }
213
337
  if (!environment) {
214
338
  return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
215
339
  'tc_run again with environment set to the chosen env key. Required โ€” the UI marks this ' +
216
340
  'field with a red asterisk.');
217
341
  }
218
- if (!browser) {
342
+ // Browser is a WEB-only field โ€” the SQA Execute dialog doesn't render it for mobile TCs
343
+ // (they show a device picker instead). Skip the refuse for ANDROID/IOS; the adapter's
344
+ // buildRunPlatform will ignore any browser passed alongside a mobile deviceId.
345
+ if (!isMobile && !browser) {
219
346
  return err("ASK the user which BROWSER to run in (chromium / chrome / msedge / firefox / webkit), " +
220
347
  'then call tc_run again with browser set. Required โ€” the UI marks this field with a ' +
221
348
  'red asterisk.');
222
349
  }
223
350
  if (!mode) {
224
- return err("Specify where to run: call tc_run again with mode: 'local' or 'cloud'. " +
225
- 'If the user has not said which, ask them first โ€” local runs on a local agent, ' +
226
- 'cloud runs on an online cloud runner VM.');
351
+ return err("Specify where to run: call tc_run again with mode: 'local', 'remote', or 'cloud'. " +
352
+ "If the user has not said which, ASK them first โ€” local runs on THIS machine's agent, " +
353
+ "remote runs on a teammate's registered agent, cloud runs on an online cloud runner VM.");
227
354
  }
228
355
  // Never assume now-vs-scheduled โ€” it decides which agents are eligible (online-only for now,
229
356
  // online+offline for a local schedule) and, for a schedule, the start time asked next.
@@ -258,13 +385,48 @@ export function registerRunTools(server, deps) {
258
385
  }
259
386
  // when:'schedule' with no cloudRunnerId โ†’ leave unpinned; the backend auto-picks a live VM at fire time.
260
387
  }
261
- // LOCAL run-NOW to a specific agent must be online (can't dispatch to an offline agent). A local
262
- // SCHEDULE may target an offline agent โ€” that path is handled in the schedule branch below.
263
- if (mode === 'local' && when === 'immediate' && agentId) {
388
+ // LOCAL/REMOTE run-NOW to a specific agent must be online (can't dispatch to an offline agent).
389
+ // The SCHEDULE branch below allows an offline agent since it runs when it fires. REMOTE also
390
+ // REQUIRES an explicit agentId at all times โ€” auto-pick across teammate boxes is unsafe.
391
+ if (mode === 'remote' && !agentId) {
392
+ return err("REMOTE runs need an explicit agentId. Call remote_agents to see the teammate machines, " +
393
+ "then pass one as agentId (must be an ONLINE row for when:'immediate').");
394
+ }
395
+ if ((mode === 'local' || mode === 'remote') && when === 'immediate' && agentId) {
264
396
  const offlineMsg = await localAgentOfflineForNow(adapter, agentId);
265
397
  if (offlineMsg)
266
398
  return err(offlineMsg);
267
399
  }
400
+ // Mobile refuse cascade โ€” asked AFTER mode/when so the client can filter the device list by
401
+ // where it's going to run. Order mirrors the UI: pick the KIND of device (Real / Emulator /
402
+ // Cloud โ€ฆ), then the specific device off the matching tab. Cloud tabs show even in LOCAL
403
+ // mode (see device-dropdown.component.html:104-111) โ€” LOCAL orchestrator can drive a cloud
404
+ // device via Appium.
405
+ if (isMobile) {
406
+ if (!deviceType) {
407
+ const platform = tcType.toLowerCase();
408
+ const localKinds = platform === 'ios' ? "'real_device', 'simulator'" : "'real_device', 'emulator'";
409
+ return err(`ASK the user which DEVICE TYPE this ${tcType} run should use: ${localKinds}, or ` +
410
+ `'cloud_device' / 'cloud_emulator' / 'cloud_simulator' for a farm device. Then call ` +
411
+ `tc_run again with deviceType set. (Also call mobile_list_devices({ platform: '${platform}' }) ` +
412
+ `to show the available devices grouped by kind โ€” the user picks a device and passes ` +
413
+ `\`id\` as deviceId, its \`deviceType\`, and โ€” for cloud rows โ€” its \`cloudDeviceType\`.)`);
414
+ }
415
+ const isCloudPick = deviceType.startsWith('cloud_');
416
+ if (isCloudPick && !cloudDeviceType) {
417
+ const platform = tcType.toLowerCase();
418
+ return err(`Cloud device picks need \`cloudDeviceType\` โ€” which farm the device came from. Call ` +
419
+ `mobile_list_devices({ platform: '${platform}' }); the matching row's \`cloudDeviceType\` ` +
420
+ `is one of 'lambda_test' / 'browser_stack' / 'sauce_labs' / 'simplify_qa_device_form'. ` +
421
+ `Pass it alongside deviceType and deviceId.`);
422
+ }
423
+ if (!deviceId) {
424
+ const platform = tcType.toLowerCase();
425
+ return err(`ASK the user which DEVICE to run on. Call mobile_list_devices({ platform: '${platform}' }) ` +
426
+ `and offer the rows whose deviceType matches '${deviceType}' โ€” the user picks one; pass its ` +
427
+ `\`id\` as deviceId (and optionally its \`osVersion\`) to tc_run.`);
428
+ }
429
+ }
268
430
  if (when === 'schedule') {
269
431
  if (!adapter.createSchedule)
270
432
  return err('This product does not support scheduling runs.');
@@ -273,7 +435,8 @@ export function registerRunTools(server, deps) {
273
435
  "ISO datetime (e.g. '2026-08-20T09:00:00Z'). Ask the user when the run should start (and the " +
274
436
  'recurrence, end date and timezone if any).');
275
437
  }
276
- // A schedule fires detached from any live session โ€” a LOCAL schedule must pin a specific agent.
438
+ // A schedule fires detached from any live session โ€” a LOCAL or REMOTE schedule must pin a
439
+ // specific agent (mode:'remote' already fenced above, so this catches LOCAL).
277
440
  if (mode === 'local' && !agentId)
278
441
  return err(await localScheduleNeedsAgent(adapter));
279
442
  // Cloud: pin the runner's CREDENTIAL only when the user explicitly picked one; otherwise leave
@@ -282,7 +445,7 @@ export function registerRunTools(server, deps) {
282
445
  const schedOpts = {
283
446
  mode,
284
447
  scheduledFor,
285
- ...(mode === 'local' && agentId ? { agentId } : {}),
448
+ ...((mode === 'local' || mode === 'remote') && agentId ? { agentId } : {}),
286
449
  ...(pinnedRunner?.credentialId ? { cloudRunnerCredentialId: pinnedRunner.credentialId } : {}),
287
450
  ...(browser !== undefined ? { browser } : {}),
288
451
  ...(environment !== undefined ? { environmentId: environment } : {}),
@@ -306,12 +469,15 @@ export function registerRunTools(server, deps) {
306
469
  }
307
470
  const opts = {
308
471
  mode,
309
- ...(mode === 'local' && agentId ? { agentId } : {}),
472
+ ...((mode === 'local' || mode === 'remote') && agentId ? { agentId } : {}),
310
473
  ...(environment !== undefined ? { environment } : {}),
311
474
  ...(browser !== undefined ? { browser } : {}),
312
475
  ...(headless !== undefined ? { headless } : {}),
313
476
  ...(resolvedRunner ? { cloudRunnerId: resolvedRunner.id } : {}),
314
477
  ...(deviceId !== undefined ? { deviceId } : {}),
478
+ ...(deviceType !== undefined ? { deviceType: deviceType.toUpperCase() } : {}),
479
+ ...(cloudDeviceType !== undefined ? { cloudDeviceType: cloudDeviceType.toUpperCase() } : {}),
480
+ ...(osVersion !== undefined ? { osVersion } : {}),
315
481
  ...(releaseId ? { releaseId } : {}),
316
482
  ...(sprintId ? { sprintId } : {}),
317
483
  ...(versionId ? { versionId } : {}),
@@ -320,7 +486,9 @@ export function registerRunTools(server, deps) {
320
486
  const run = await adapter.startRun({ tcId }, opts);
321
487
  const where = mode === 'cloud'
322
488
  ? `on cloud runner ${resolvedRunner ? `**${resolvedRunner.name}**` : ''}`
323
- : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
489
+ : mode === 'remote'
490
+ ? `on remote agent \`${agentId}\``
491
+ : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
324
492
  return ok(`Started ${tcId} ${where}. Run id \`${run.runId}\` โ€” poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
325
493
  }
326
494
  catch (e) {
@@ -328,20 +496,22 @@ export function registerRunTools(server, deps) {
328
496
  }
329
497
  });
330
498
  server.registerTool('suite_run', {
331
- title: 'Run a test suite (local or cloud; now or scheduled)',
499
+ title: 'Run a test suite (local / remote / cloud; now or scheduled)',
332
500
  description: 'Execute an existing test SUITE โ€” all its member test cases, in order โ€” in the connected product. ' +
333
501
  'ASK the user for EVERY field the UI\'s Execute-suite dialog asks โ€” never assume โ€” and in ORDER ' +
334
502
  '(mirrors the product UI, `_parentComponent==="SU"`): (1) `releaseId` (from list_releases โ€” the UI ' +
335
503
  'marks it required with `*` for a suite), (2) `sprintId` (from list_sprints โ€” also `*` for a suite), ' +
336
- '(3) `environment` (from tc_list_environments โ€” `*`), (4) `mode` local/cloud, (5) `when` immediate/schedule, ' +
504
+ '(3) `environment` (from tc_list_environments โ€” `*`), (4) `mode` local/remote/cloud, (5) `when` immediate/schedule, ' +
337
505
  '(6) for a schedule the start time + recurrence + timezone, (7) THEN offer the eligible agent/runner. ' +
338
506
  'The tool REFUSES when any of (1)โ€“(5) is omitted โ€” that is deliberate; it forces the ask instead of ' +
339
507
  'silently defaulting. UNLIKE `tc_run`, a suite does NOT ask for `versionId` or `browser` โ€” each TC ' +
340
508
  "in the suite carries its own pinned version + execution platform (that's what the suite editor picks per row). " +
341
509
  '`mode` decides WHERE it runs:\n' +
342
- " โ€ข 'local' โ€” a local agent's own browser, always serial. Pass `agentId` from local_agents. For " +
343
- "when:'immediate' only ONLINE agents are eligible (omit to auto-pick your own online agent); for " +
344
- "when:'schedule' ANY agent (online OR offline) is eligible and `agentId` is REQUIRED.\n" +
510
+ " โ€ข 'local' โ€” THIS machine's desktop agent, always serial. Call `local_agents` (0 or 1 row). For " +
511
+ "when:'immediate' the tool probes :4040 and refuses if the desktop agent is down. Omit `agentId` to " +
512
+ "auto-pick this machine's agent; for when:'schedule' `agentId` is REQUIRED.\n" +
513
+ " โ€ข 'remote' โ€” ANOTHER registered machine's desktop agent, always serial. Call `remote_agents`; " +
514
+ "`agentId` is REQUIRED (no auto-pick). when:'immediate' โ†’ ONLINE only; when:'schedule' โ†’ offline OK.\n" +
345
515
  " โ€ข 'cloud' โ€” online cloud runner VM(s) (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
346
516
  'from cloud_runners; for a schedule omit it to auto-pick a live VM at fire time.\n' +
347
517
  'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
@@ -355,8 +525,8 @@ export function registerRunTools(server, deps) {
355
525
  suiteId: z.string().describe('The suite id to run (from suite_list / suite_get).'),
356
526
  releaseId: z.string().optional().describe('REQUIRED for a suite (UI marks it `*` for _parentComponent==="SU"). A release id from list_releases. Ask the user.'),
357
527
  sprintId: z.string().optional().describe('REQUIRED for a suite (UI marks it `*` for _parentComponent==="SU"). A sprint id from list_sprints (scoped to the picked release). Ask the user.'),
358
- mode: z.enum(['local', 'cloud']).optional().describe('Where to run. REQUIRED โ€” ASK the user if unstated (the tool refuses when omitted).'),
359
- agentId: z.string().optional().describe("For local: a specific agent credential_id (from local_agents). when:'immediate' โ†’ must be ONLINE (or omit to auto-pick your own online agent); when:'schedule' โ†’ REQUIRED, may be offline."),
528
+ mode: z.enum(['local', 'remote', 'cloud']).optional().describe('Where to run. REQUIRED โ€” ASK the user if unstated (the tool refuses when omitted).'),
529
+ agentId: z.string().optional().describe("For local: a credential_id from `local_agents` (this machine only; omit to auto-pick). For remote: REQUIRED โ€” a credential_id from `remote_agents`. when:'immediate' โ†’ picked agent must be ONLINE; when:'schedule' โ†’ offline OK, agentId REQUIRED for both local and remote."),
360
530
  environment: z.string().optional().describe('Environment key (from tc_list_environments). REQUIRED โ€” the UI marks this with a red asterisk for a suite.'),
361
531
  browser: z.string().optional().describe("Optional at the SUITE level โ€” each TC in the suite has its own execution platform (that's the suite-editor pick per row). Provide only to OVERRIDE for every WEB TC. Values: 'chromium' (default), 'chrome'/'msedge', 'firefox', 'webkit'. NOTE: a schedule accepts only chromium/msedge/firefox/webkit."),
362
532
  headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
@@ -395,9 +565,9 @@ export function registerRunTools(server, deps) {
395
565
  'field with a red asterisk for a suite.');
396
566
  }
397
567
  if (!mode) {
398
- return err("Specify where to run: call suite_run again with mode: 'local' or 'cloud'. " +
399
- 'If the user has not said which, ask them first โ€” local runs on a local agent, ' +
400
- 'cloud runs on online cloud runner VM(s).');
568
+ return err("Specify where to run: call suite_run again with mode: 'local', 'remote', or 'cloud'. " +
569
+ "If the user has not said which, ASK them first โ€” local runs on THIS machine's agent, " +
570
+ "remote runs on a teammate's registered agent, cloud runs on online cloud runner VM(s).");
401
571
  }
402
572
  // Never assume now-vs-scheduled โ€” it decides which agents are eligible (online-only for now,
403
573
  // online+offline for a local schedule) and, for a schedule, the start time asked next.
@@ -432,9 +602,13 @@ export function registerRunTools(server, deps) {
432
602
  }
433
603
  // when:'schedule' with no cloudRunnerId โ†’ leave unpinned; the backend auto-picks a live VM at fire time.
434
604
  }
435
- // LOCAL run-NOW to a specific agent must be online (can't dispatch to an offline agent). A local
436
- // SCHEDULE may target an offline agent โ€” that path is handled in the schedule branch below.
437
- if (mode === 'local' && when === 'immediate' && agentId) {
605
+ // LOCAL/REMOTE run-NOW to a specific agent must be online. SCHEDULE branch below allows offline.
606
+ // REMOTE always requires an explicit agentId (auto-pick across teammate boxes is unsafe).
607
+ if (mode === 'remote' && !agentId) {
608
+ return err("REMOTE runs need an explicit agentId. Call remote_agents to see the teammate machines, " +
609
+ "then pass one as agentId (must be an ONLINE row for when:'immediate').");
610
+ }
611
+ if ((mode === 'local' || mode === 'remote') && when === 'immediate' && agentId) {
438
612
  const offlineMsg = await localAgentOfflineForNow(adapter, agentId);
439
613
  if (offlineMsg)
440
614
  return err(offlineMsg);
@@ -448,7 +622,8 @@ export function registerRunTools(server, deps) {
448
622
  'recurrence, end date and timezone if any). NOTE: strategy/maxConcurrent/terminateOnFailure are ' +
449
623
  "immediate-run only โ€” a schedule uses the suite's own settings.");
450
624
  }
451
- // A schedule fires detached from any live session โ€” a LOCAL schedule must pin a specific agent.
625
+ // A schedule fires detached from any live session โ€” LOCAL and REMOTE schedules must pin a
626
+ // specific agent. REMOTE was already fenced above; this catches LOCAL.
452
627
  if (mode === 'local' && !agentId)
453
628
  return err(await localScheduleNeedsAgent(adapter));
454
629
  // Cloud: pin the runner's CREDENTIAL only when the user explicitly picked one; otherwise leave
@@ -457,7 +632,7 @@ export function registerRunTools(server, deps) {
457
632
  const schedOpts = {
458
633
  mode,
459
634
  scheduledFor,
460
- ...(mode === 'local' && agentId ? { agentId } : {}),
635
+ ...((mode === 'local' || mode === 'remote') && agentId ? { agentId } : {}),
461
636
  ...(pinnedRunner?.credentialId ? { cloudRunnerCredentialId: pinnedRunner.credentialId } : {}),
462
637
  ...(browser !== undefined ? { browser } : {}),
463
638
  ...(environment !== undefined ? { environmentId: environment } : {}),
@@ -481,7 +656,7 @@ export function registerRunTools(server, deps) {
481
656
  }
482
657
  const opts = {
483
658
  mode,
484
- ...(mode === 'local' && agentId ? { agentId } : {}),
659
+ ...((mode === 'local' || mode === 'remote') && agentId ? { agentId } : {}),
485
660
  ...(environment !== undefined ? { environment } : {}),
486
661
  ...(browser !== undefined ? { browser } : {}),
487
662
  ...(headless !== undefined ? { headless } : {}),
@@ -496,7 +671,9 @@ export function registerRunTools(server, deps) {
496
671
  const run = await adapter.startRun({ suiteId }, opts);
497
672
  const where = mode === 'cloud'
498
673
  ? `on cloud runner ${resolvedRunner ? `**${resolvedRunner.name}**` : ''}`
499
- : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
674
+ : mode === 'remote'
675
+ ? `on remote agent \`${agentId}\``
676
+ : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
500
677
  return ok(`Started suite ${suiteId} ${where}. Run id \`${run.runId}\` โ€” poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
501
678
  }
502
679
  catch (e) {
@@ -505,16 +682,33 @@ export function registerRunTools(server, deps) {
505
682
  });
506
683
  server.registerTool('run_status', {
507
684
  title: 'Get a run status',
508
- description: 'Read the status of a run started by tc_run โ€” state (queued/running/passed/failed/stopped/error), ' +
509
- 'step progress, and a link. Requires a live connection.',
510
- inputSchema: { runId: z.string().describe('The run id returned by tc_run.') },
685
+ description: 'Read the status of a run โ€” state (queued/running/passed/failed/stopped/error), progress, ' +
686
+ 'per-test-case rollup for a SUITE run (capped at 100 shown), and a link. Pass EITHER `runId` ' +
687
+ '(from tc_run / suite_run) or `suiteId` (from suite_list) to read that suite\'s LATEST ' +
688
+ 'execution. Requires a live connection.',
689
+ inputSchema: {
690
+ runId: z.string().optional().describe('The run id returned by tc_run or suite_run.'),
691
+ suiteId: z.string().optional().describe('A suite id (from suite_list) โ€” resolves to the suite\'s latest execution.'),
692
+ },
511
693
  annotations: { readOnlyHint: true },
512
- }, async ({ runId }) => {
694
+ }, async ({ runId, suiteId }) => {
513
695
  const adapter = deps.getAdapter();
514
696
  if (!adapter)
515
697
  return err(NOT_CONNECTED);
698
+ if ((runId && suiteId) || (!runId && !suiteId)) {
699
+ return err('Pass exactly one of `runId` or `suiteId`.');
700
+ }
516
701
  try {
517
- const status = await adapter.getRun(runId);
702
+ let effectiveRunId = runId;
703
+ if (suiteId) {
704
+ if (!adapter.getLatestSuiteRun)
705
+ return err('This product does not support latest-suite-run lookup โ€” pass `runId` instead.');
706
+ const latest = await adapter.getLatestSuiteRun(suiteId);
707
+ if (!latest)
708
+ return ok(`Suite ${suiteId} has no executions yet.`);
709
+ effectiveRunId = latest;
710
+ }
711
+ const status = await adapter.getRun(effectiveRunId);
518
712
  return ok(statusText(status), { run: status });
519
713
  }
520
714
  catch (e) {