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
@@ -5,7 +5,63 @@ function describeError(e) {
5
5
  return e instanceof Error ? e.message : String(e);
6
6
  }
7
7
  function runnerLine(r) {
8
- return `- ${r.online ? '🟢' : '⚪'} ${r.name} \`${r.id}\`${r.status ? ` (${r.status})` : ''}`;
8
+ const host = r.os ? ` · ${r.os}` : '';
9
+ return `- ${r.online ? '🟢' : '⚪'} ${r.name}${host} \`${r.id}\`${r.status ? ` (${r.status})` : ''}`;
10
+ }
11
+ function localAgentLine(a) {
12
+ // Show the machine hostname next to the owner (like the product UI) so the user can tell apart
13
+ // several agents owned by the same person; the credential_id in backticks is for the tool call.
14
+ const host = a.host && a.host !== a.name ? ` · ${a.host}` : '';
15
+ return `- ${a.online ? '🟢' : '⚪'} ${a.name ?? a.id}${host} \`${a.id}\`${a.status ? ` (${a.status})` : ''}`;
16
+ }
17
+ /** One line per schedule for management listings — what it runs, cadence, where + pinned agent,
18
+ * browser, next run, status; the credential-free id in backticks is what the management tools take. */
19
+ function scheduleLine(s) {
20
+ const where = s.executionTarget === 'cloud_runner' ? 'cloud' : 'local';
21
+ const agent = s.agent ? ` → ${s.agent}` : '';
22
+ const br = s.browser ? ` · ${s.browser}` : '';
23
+ const next = s.nextRunAt ? ` · next ${s.nextRunAt}` : s.scheduledFor ? ` · at ${s.scheduledFor}` : '';
24
+ const paused = s.active === false ? ' [paused]' : '';
25
+ return `- ${s.target} — ${s.scheduleType}, ${where}${agent}${br}${next} · **${s.status}**${paused} \`${s.id}\``;
26
+ }
27
+ /** A schedule fires detached from any live session, so a LOCAL schedule must target a specific
28
+ * agent — there is no "your online agent" at fire time. Build the "pick an agent" message, offering
29
+ * ALL agents (online AND offline): a scheduled run can target an agent that is offline right now,
30
+ * because it runs when it fires — this mirrors the product's scheduler picker. */
31
+ async function localScheduleNeedsAgent(adapter) {
32
+ let list = '';
33
+ if (adapter.listLocalAgents) {
34
+ try {
35
+ const agents = await adapter.listLocalAgents();
36
+ list = agents.length
37
+ ? '\n\nAgents (a scheduled run can target an offline one — it runs when it fires):\n' +
38
+ agents.map(localAgentLine).join('\n')
39
+ : '\n\nNo local agents are registered to this project.';
40
+ }
41
+ catch { /* best-effort listing — the core message still stands */ }
42
+ }
43
+ return ('A LOCAL schedule runs detached from any live session, so it must target a specific agent. ' +
44
+ 'Call again with `agentId` set to a credential_id from local_agents.' + list);
45
+ }
46
+ /** A run-NOW to a specific LOCAL agent cannot target an offline one (mirrors the product UI, which
47
+ * offers only ONLINE agents for an immediate run). Returns an error message when the picked agent is
48
+ * known-offline, else null. Roster errors are swallowed — the backend still validates at reserve. */
49
+ async function localAgentOfflineForNow(adapter, agentId) {
50
+ if (!adapter.listLocalAgents)
51
+ return null;
52
+ try {
53
+ const agents = await adapter.listLocalAgents();
54
+ const picked = agents.find((a) => a.id === agentId);
55
+ if (picked && !picked.online) {
56
+ const online = agents.filter((a) => a.online);
57
+ return (`Agent \`${agentId}\`${picked.name ? ` (${picked.name})` : ''} is offline — a run-now needs an ONLINE agent. ` +
58
+ (online.length
59
+ ? 'Online now:\n' + online.map(localAgentLine).join('\n')
60
+ : 'No local agents are online right now — schedule it instead, or bring an agent online.'));
61
+ }
62
+ }
63
+ catch { /* roster unavailable — let the backend validate at reserve */ }
64
+ return null;
9
65
  }
10
66
  /** Human summary of a run's status. For a SUITE run `children` carries the per-test-case rollup and
11
67
  * `progress` counts TEST CASES; a single test-case run has neither (progress counts steps). */
@@ -14,10 +70,22 @@ function statusText(s) {
14
70
  const prog = s.progress ? ` · ${s.progress.done}/${s.progress.total} ${unit}` : '';
15
71
  const link = s.reportUrl ? `\n${s.reportUrl}` : '';
16
72
  const icon = (state) => (state === 'passed' ? '✅' : state === 'failed' ? '❌' : state === 'running' ? '⏳' : '•');
73
+ // Per-state tally across children (suite runs only). Kept concise — only non-zero buckets, in a
74
+ // stable order so the header reads the same every poll.
75
+ const tally = s.children?.length
76
+ ? (() => {
77
+ const order = ['passed', 'failed', 'running', 'queued', 'stopped', 'error'];
78
+ const counts = new Map();
79
+ for (const c of s.children)
80
+ counts.set(c.state, (counts.get(c.state) ?? 0) + 1);
81
+ const parts = order.filter((k) => counts.get(k)).map((k) => `${counts.get(k)} ${k}`);
82
+ return parts.length ? ` · ${parts.join(', ')}` : '';
83
+ })()
84
+ : '';
17
85
  const kids = s.children?.length
18
86
  ? '\n\n' + s.children.map((c) => `- ${icon(c.state)} ${c.id}${c.title ? ` — ${c.title}` : ''} (${c.state})`).join('\n')
19
87
  : '';
20
- return `Run ${s.runId} · **${s.state}**${prog}${link}${kids}`;
88
+ return `Run ${s.runId} · **${s.state}**${prog}${tally}${link}${kids}`;
21
89
  }
22
90
  export function registerRunTools(server, deps) {
23
91
  server.registerTool('cloud_runners', {
@@ -45,32 +113,118 @@ export function registerRunTools(server, deps) {
45
113
  return err(describeError(e));
46
114
  }
47
115
  });
116
+ server.registerTool('local_agents', {
117
+ title: 'List local agents (and which are online)',
118
+ description: 'List the LOCAL agents registered to the connected customer and which are online. Use this ' +
119
+ 'to show the user their local run options, or to pick a specific agent id (its credential_id) to ' +
120
+ 'pass as `agentId` to a LOCAL tc_run / suite_run. Requires a live connection. ' +
121
+ 'NOTE on online status: agents whose `machineName` matches this MCP host are reported online ' +
122
+ '(mirrors sqa-ui\'s own-machine override — the DB `active` flag is only refreshed at first ' +
123
+ 'registration + a stale cron, so it lags behind). Other agents\' status still comes from the DB.',
124
+ inputSchema: {},
125
+ annotations: { readOnlyHint: true },
126
+ }, async () => {
127
+ const adapter = deps.getAdapter();
128
+ if (!adapter)
129
+ return err(NOT_CONNECTED);
130
+ if (!adapter.listLocalAgents)
131
+ return err('This product has no local agents to list.');
132
+ try {
133
+ const agents = await adapter.listLocalAgents();
134
+ const online = agents.filter((a) => a.online);
135
+ if (!agents.length)
136
+ return ok('No local agents are registered to this project.', { agents: [] });
137
+ const text = `${agents.length} local agent${agents.length === 1 ? '' : 's'} · ${online.length} online:\n\n` +
138
+ agents.map(localAgentLine).join('\n');
139
+ return ok(text, { agents, online: online.length });
140
+ }
141
+ catch (e) {
142
+ return err(describeError(e));
143
+ }
144
+ });
48
145
  server.registerTool('tc_run', {
49
- title: 'Run a test case (local or cloud)',
50
- description: 'Execute ONE existing test case in the connected product. `mode` decides WHERE it runs:\n' +
51
- " • 'local' — the connected agent's own browser\n" +
52
- " • 'cloud' — an online cloud runner VM\n" +
53
- 'IF THE USER HAS NOT SAID local or cloud, ASK THEM before calling — do not guess. When mode is ' +
54
- 'omitted this tool refuses with that instruction. For cloud, if no runner is online it reports that ' +
55
- 'there are no active cloud runners (nothing is run). Returns the run id — poll run_status for the result.',
146
+ title: 'Run a test case (local or cloud; now or scheduled)',
147
+ description: "Execute ONE existing test case in the connected product. ASK the user for EVERY field the " +
148
+ "Execute dialog asks — never assume — and in ORDER: (1) `releaseId` (from list_releases; " +
149
+ "pass '' if the user skips), (2) `sprintId` (from list_sprints; pass '' if the user skips), " +
150
+ "(3) `versionId` (from tc_get's `versions[]`; pass '' to accept the TC's current version), " +
151
+ '(4) `environment` (from tc_list_environments — required), (5) `browser` (required), ' +
152
+ '(6) `mode` local/cloud, (7) `when` immediate/schedule, (8) for a schedule the start time + ' +
153
+ 'recurrence + timezone, (9) THEN offer the eligible agent/runner and pass the pick. The tool ' +
154
+ 'REFUSES when any of (1)-(7) is omitted — that is deliberate; it forces the ask instead of ' +
155
+ 'silently defaulting. `mode` decides WHERE it runs:\n' +
156
+ " • 'local' — a local agent's own browser. Pass `agentId` from local_agents. For when:'immediate' only " +
157
+ "ONLINE agents are eligible (a run-now can't target an offline agent; omit to auto-pick your own online " +
158
+ "agent). For when:'schedule' ANY agent (online OR offline) is eligible and `agentId` is REQUIRED.\n" +
159
+ " • 'cloud' — an online cloud runner VM (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
160
+ 'from cloud_runners; for a schedule omit it to let the backend auto-pick a live VM at fire time.\n' +
161
+ 'IF THE USER HAS NOT SAID local or cloud, ASK — this tool refuses when `mode` is omitted. For cloud, if ' +
162
+ 'no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
163
+ '`when` decides WHEN it runs (this tool REFUSES when `when` is omitted — ASK the user):\n' +
164
+ " • 'immediate' — dispatch now; returns the run id — poll run_status for the result.\n" +
165
+ " • 'schedule' — create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
166
+ 'end date and timezone BEFORE offering an agent. Returns the schedule id.\n' +
167
+ 'NOTE: MANUAL test cases cannot be run or scheduled by an agent — the backend rejects them.',
56
168
  inputSchema: {
57
169
  tcId: z.string().describe('The test case id (or business key) to run.'),
58
- mode: z.enum(['local', 'cloud']).optional().describe("Where to run. REQUIRED in practice — ask the user if unstated."),
59
- environment: z.string().optional().describe('Environment id (Mongo id); a name is ignored and the project default is used.'),
60
- browser: z.string().optional().describe("Browser, default 'chromium' (bundled + isolated). Others: 'chrome'/'msedge' (installed channel), 'firefox', 'webkit'."),
61
- headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false.'),
62
- cloudRunnerId: z.string().optional().describe('For cloud: a specific runner id (from cloud_runners). Omit to auto-pick an online one.'),
170
+ mode: z.enum(['local', 'cloud']).optional().describe("Where to run. REQUIRED — ASK the user if unstated (the tool refuses when omitted)."),
171
+ 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)."),
172
+ releaseId: z.string().optional().describe('Tag the run with a RELEASE (from list_releases). Optional in the UI; omit to leave untagged.'),
173
+ sprintId: z.string().optional().describe('Tag the run with a SPRINT (from list_sprints). Optional in the UI; omit to leave untagged.'),
174
+ 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."),
175
+ environment: z.string().optional().describe('Environment key (from tc_list_environments). ASK the user before running — the UI marks this required.'),
176
+ 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."),
177
+ headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
178
+ 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."),
179
+ 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."),
180
+ 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)."),
181
+ scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' — ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
182
+ scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
183
+ endDate: z.string().optional().describe('For a recurring schedule — ISO end date (required for hourly/daily/weekdays).'),
184
+ timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
63
185
  },
64
186
  annotations: { readOnlyHint: false },
65
- }, async ({ tcId, mode, environment, browser, headless, cloudRunnerId }) => {
187
+ }, async ({ tcId, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, when, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
66
188
  const adapter = deps.getAdapter();
67
189
  if (!adapter)
68
190
  return err(NOT_CONNECTED);
191
+ // Mirror the Execute-dialog required-field enforcement — ASK the user for every field the UI
192
+ // asks. Release/sprint/version are optional in the UI; pass '' after asking to accept the
193
+ // default (untagged / TC's current version). Environment and browser have red asterisks.
194
+ if (releaseId === undefined) {
195
+ return err("ASK the user which RELEASE this run belongs to (from list_releases), then call tc_run " +
196
+ "again with releaseId. If the user wants no release, pass releaseId: '' (empty string).");
197
+ }
198
+ if (sprintId === undefined) {
199
+ return err("ASK the user which SPRINT this run belongs to (from list_sprints), then call tc_run " +
200
+ "again with sprintId. If the user wants no sprint, pass sprintId: '' (empty string).");
201
+ }
202
+ if (versionId === undefined) {
203
+ return err("ASK the user which VERSION of the test case to run (from tc_get's `versions[]`), then " +
204
+ "call tc_run again with versionId. To accept the TC's currently-selected version, " +
205
+ "pass versionId: '' (empty string).");
206
+ }
207
+ if (!environment) {
208
+ return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
209
+ 'tc_run again with environment set to the chosen env key. Required — the UI marks this ' +
210
+ 'field with a red asterisk.');
211
+ }
212
+ if (!browser) {
213
+ return err("ASK the user which BROWSER to run in (chromium / chrome / msedge / firefox / webkit), " +
214
+ 'then call tc_run again with browser set. Required — the UI marks this field with a ' +
215
+ 'red asterisk.');
216
+ }
69
217
  if (!mode) {
70
218
  return err("Specify where to run: call tc_run again with mode: 'local' or 'cloud'. " +
71
- 'If the user has not said which, ask them first — local runs on the connected agent, ' +
219
+ 'If the user has not said which, ask them first — local runs on a local agent, ' +
72
220
  'cloud runs on an online cloud runner VM.');
73
221
  }
222
+ // Never assume now-vs-scheduled — it decides which agents are eligible (online-only for now,
223
+ // online+offline for a local schedule) and, for a schedule, the start time asked next.
224
+ if (!when) {
225
+ return err("Specify WHEN to run: call tc_run again with when: 'immediate' (run now) or when: 'schedule' " +
226
+ '(create a schedule). If the user has not said, ask them first — never assume.');
227
+ }
74
228
  let resolvedRunner;
75
229
  if (mode === 'cloud') {
76
230
  if (!adapter.listCloudRunners)
@@ -82,6 +236,7 @@ export function registerRunTools(server, deps) {
82
236
  catch (e) {
83
237
  return err(describeError(e));
84
238
  }
239
+ // Cloud is ONLINE-only whether now or scheduled (a schedule pin to an offline VM is rejected).
85
240
  const online = runners.filter((r) => r.online);
86
241
  if (!online.length) {
87
242
  return err('Currently there are no active cloud runners in your project.');
@@ -92,22 +247,74 @@ export function registerRunTools(server, deps) {
92
247
  return err(`Cloud runner "${cloudRunnerId}" is not online. Online now:\n` + online.map(runnerLine).join('\n'));
93
248
  }
94
249
  }
95
- else {
96
- resolvedRunner = online[0]; // auto-pick the first online runner
250
+ else if (when === 'immediate') {
251
+ resolvedRunner = online[0]; // run-now with no pick → auto-pick the first online runner
252
+ }
253
+ // when:'schedule' with no cloudRunnerId → leave unpinned; the backend auto-picks a live VM at fire time.
254
+ }
255
+ // LOCAL run-NOW to a specific agent must be online (can't dispatch to an offline agent). A local
256
+ // SCHEDULE may target an offline agent — that path is handled in the schedule branch below.
257
+ if (mode === 'local' && when === 'immediate' && agentId) {
258
+ const offlineMsg = await localAgentOfflineForNow(adapter, agentId);
259
+ if (offlineMsg)
260
+ return err(offlineMsg);
261
+ }
262
+ if (when === 'schedule') {
263
+ if (!adapter.createSchedule)
264
+ return err('This product does not support scheduling runs.');
265
+ if (!scheduledFor) {
266
+ return err("Scheduling needs a start time: call tc_run again with when: 'schedule' and scheduledFor as an " +
267
+ "ISO datetime (e.g. '2026-08-20T09:00:00Z'). Ask the user when the run should start (and the " +
268
+ 'recurrence, end date and timezone if any).');
269
+ }
270
+ // A schedule fires detached from any live session — a LOCAL schedule must pin a specific agent.
271
+ if (mode === 'local' && !agentId)
272
+ return err(await localScheduleNeedsAgent(adapter));
273
+ // Cloud: pin the runner's CREDENTIAL only when the user explicitly picked one; otherwise leave
274
+ // it unpinned so the backend auto-picks a live VM at fire time (don't freeze today's online[0]).
275
+ const pinnedRunner = mode === 'cloud' && cloudRunnerId ? resolvedRunner : undefined;
276
+ const schedOpts = {
277
+ mode,
278
+ scheduledFor,
279
+ ...(mode === 'local' && agentId ? { agentId } : {}),
280
+ ...(pinnedRunner?.credentialId ? { cloudRunnerCredentialId: pinnedRunner.credentialId } : {}),
281
+ ...(browser !== undefined ? { browser } : {}),
282
+ ...(environment !== undefined ? { environmentId: environment } : {}),
283
+ ...(releaseId ? { releaseId } : {}),
284
+ ...(sprintId ? { sprintId } : {}),
285
+ ...(scheduleType !== undefined ? { scheduleType } : {}),
286
+ ...(endDate !== undefined ? { endDate } : {}),
287
+ ...(timezone !== undefined ? { timezone } : {}),
288
+ };
289
+ try {
290
+ const sched = await adapter.createSchedule({ tcId }, schedOpts);
291
+ const next = sched.nextRunAt ? ` · first run ${sched.nextRunAt}` : '';
292
+ const where = mode === 'cloud'
293
+ ? (pinnedRunner ? `cloud runner **${pinnedRunner.name}**` : 'cloud (auto-pick a live VM at run time)')
294
+ : `agent \`${agentId}\``;
295
+ return ok(`Scheduled ${tcId} (${scheduleType ?? 'once'}, ${mode}) for ${scheduledFor}${next} → ${where}. Schedule id \`${sched.id}\`.`, { schedule: sched, mode, when: 'schedule', ...(pinnedRunner ? { runner: pinnedRunner } : {}) });
296
+ }
297
+ catch (e) {
298
+ return err(describeError(e));
97
299
  }
98
300
  }
99
301
  const opts = {
100
302
  mode,
303
+ ...(mode === 'local' && agentId ? { agentId } : {}),
101
304
  ...(environment !== undefined ? { environment } : {}),
102
305
  ...(browser !== undefined ? { browser } : {}),
103
306
  ...(headless !== undefined ? { headless } : {}),
104
307
  ...(resolvedRunner ? { cloudRunnerId: resolvedRunner.id } : {}),
308
+ ...(deviceId !== undefined ? { deviceId } : {}),
309
+ ...(releaseId ? { releaseId } : {}),
310
+ ...(sprintId ? { sprintId } : {}),
311
+ ...(versionId ? { versionId } : {}),
105
312
  };
106
313
  try {
107
314
  const run = await adapter.startRun({ tcId }, opts);
108
315
  const where = mode === 'cloud'
109
316
  ? `on cloud runner ${resolvedRunner ? `**${resolvedRunner.name}**` : ''}`
110
- : 'locally on the connected agent';
317
+ : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
111
318
  return ok(`Started ${tcId} ${where}. Run id \`${run.runId}\` — poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
112
319
  }
113
320
  catch (e) {
@@ -115,36 +322,83 @@ export function registerRunTools(server, deps) {
115
322
  }
116
323
  });
117
324
  server.registerTool('suite_run', {
118
- title: 'Run a test suite (local or cloud)',
325
+ title: 'Run a test suite (local or cloud; now or scheduled)',
119
326
  description: 'Execute an existing test SUITE — all its member test cases, in order — in the connected product. ' +
327
+ 'ASK the user for EVERY field the UI\'s Execute-suite dialog asks — never assume — and in ORDER ' +
328
+ '(mirrors the product UI, `_parentComponent==="SU"`): (1) `releaseId` (from list_releases — the UI ' +
329
+ 'marks it required with `*` for a suite), (2) `sprintId` (from list_sprints — also `*` for a suite), ' +
330
+ '(3) `environment` (from tc_list_environments — `*`), (4) `mode` local/cloud, (5) `when` immediate/schedule, ' +
331
+ '(6) for a schedule the start time + recurrence + timezone, (7) THEN offer the eligible agent/runner. ' +
332
+ 'The tool REFUSES when any of (1)–(5) is omitted — that is deliberate; it forces the ask instead of ' +
333
+ 'silently defaulting. UNLIKE `tc_run`, a suite does NOT ask for `versionId` or `browser` — each TC ' +
334
+ "in the suite carries its own pinned version + execution platform (that's what the suite editor picks per row). " +
120
335
  '`mode` decides WHERE it runs:\n' +
121
- " • 'local' — the connected agent's own browser (always serial)\n" +
122
- " • 'cloud' — online cloud runner VM(s)\n" +
123
- 'IF THE USER HAS NOT SAID local or cloud, ASK THEM before calling — do not guess (this tool refuses ' +
124
- 'when mode is omitted). For cloud, if no runner is online it reports that there are no active cloud ' +
125
- 'runners (nothing is run). Returns the suite-run id — poll run_status for progress (it reports ' +
126
- 'per-test-case results under `children`) and run_stop to cancel.',
336
+ " • 'local' — a local agent's own browser, always serial. Pass `agentId` from local_agents. For " +
337
+ "when:'immediate' only ONLINE agents are eligible (omit to auto-pick your own online agent); for " +
338
+ "when:'schedule' ANY agent (online OR offline) is eligible and `agentId` is REQUIRED.\n" +
339
+ " • 'cloud' — online cloud runner VM(s) (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
340
+ 'from cloud_runners; for a schedule omit it to auto-pick a live VM at fire time.\n' +
341
+ 'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
342
+ '`when` decides WHEN it runs:\n' +
343
+ " • 'immediate' — dispatch now; returns the suite-run id — poll run_status for progress " +
344
+ '(per-test-case results under `children`) and run_stop to cancel.\n' +
345
+ " • 'schedule' — create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
346
+ 'end date and timezone BEFORE offering an agent. Returns the schedule id.\n' +
347
+ 'NOTE: a suite of only MANUAL test cases cannot be run or scheduled by an agent — the backend rejects it.',
127
348
  inputSchema: {
128
349
  suiteId: z.string().describe('The suite id to run (from suite_list / suite_get).'),
129
- mode: z.enum(['local', 'cloud']).optional().describe('Where to run. REQUIRED in practice — ask the user if unstated.'),
130
- environment: z.string().optional().describe('Environment id (Mongo id); a name is ignored and the project default is used.'),
131
- browser: z.string().optional().describe("Browser, default 'chromium'. Others: 'chrome'/'msedge', 'firefox', 'webkit'."),
132
- headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false.'),
133
- cloudRunnerId: z.string().optional().describe('For cloud: a specific runner id (from cloud_runners). Omit to auto-pick an online one.'),
134
- strategy: z.enum(['serial', 'batch', 'dynamic']).optional().describe('Cloud dispatch order across the suite. Default serial; local is always serial.'),
135
- maxConcurrent: z.number().int().positive().optional().describe('Cloud batch/dynamic only — max test cases running at once.'),
136
- terminateOnFailure: z.boolean().optional().describe('Stop dispatching remaining test cases after the first failure.'),
350
+ releaseId: z.string().optional().describe('REQUIRED for a suite (UI marks it `*` for _parentComponent==="SU"). A release id from list_releases. Ask the user.'),
351
+ 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.'),
352
+ mode: z.enum(['local', 'cloud']).optional().describe('Where to run. REQUIRED — ASK the user if unstated (the tool refuses when omitted).'),
353
+ 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."),
354
+ environment: z.string().optional().describe('Environment key (from tc_list_environments). REQUIRED — the UI marks this with a red asterisk for a suite.'),
355
+ 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."),
356
+ headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
357
+ 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."),
358
+ strategy: z.enum(['serial', 'batch', 'dynamic']).optional().describe('Cloud dispatch order across the suite (immediate runs). Default serial; local is always serial.'),
359
+ maxConcurrent: z.number().int().positive().optional().describe('Cloud batch/dynamic only — max test cases running at once (immediate runs).'),
360
+ terminateOnFailure: z.boolean().optional().describe('Stop dispatching remaining test cases after the first failure (immediate runs).'),
361
+ 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)."),
362
+ scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' — ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
363
+ scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
364
+ endDate: z.string().optional().describe('For a recurring schedule — ISO end date (required for hourly/daily/weekdays).'),
365
+ timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
137
366
  },
138
367
  annotations: { readOnlyHint: false },
139
- }, async ({ suiteId, mode, environment, browser, headless, cloudRunnerId, strategy, maxConcurrent, terminateOnFailure }) => {
368
+ }, async ({ suiteId, releaseId, sprintId, mode, agentId, environment, browser, headless, cloudRunnerId, strategy, maxConcurrent, terminateOnFailure, when, scheduledFor, scheduleType, endDate, timezone }) => {
140
369
  const adapter = deps.getAdapter();
141
370
  if (!adapter)
142
371
  return err(NOT_CONNECTED);
372
+ // Mirror the Execute-suite dialog required-field enforcement (_parentComponent==='SU' branch of
373
+ // execution-popup-modal.component.html) — release, sprint, environment all carry a red `*` for
374
+ // a suite. UNLIKE tc_run, empty-string is NOT accepted: the UI blocks form submit until each is
375
+ // picked. `versionId` and `browser` are TC-level in a suite (each row has its own) so we
376
+ // don't ask for them.
377
+ if (!releaseId) {
378
+ return err("ASK the user which RELEASE this suite run belongs to (from list_releases), then call " +
379
+ 'suite_run again with releaseId. Required for a suite — the UI marks it with a red asterisk.');
380
+ }
381
+ if (!sprintId) {
382
+ return err("ASK the user which SPRINT this suite run belongs to (from list_sprints, filtered by " +
383
+ 'releaseId), then call suite_run again with sprintId. Required for a suite — the UI marks it ' +
384
+ 'with a red asterisk.');
385
+ }
386
+ if (!environment) {
387
+ return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
388
+ 'suite_run again with environment set to the chosen env key. Required — the UI marks this ' +
389
+ 'field with a red asterisk for a suite.');
390
+ }
143
391
  if (!mode) {
144
392
  return err("Specify where to run: call suite_run again with mode: 'local' or 'cloud'. " +
145
- 'If the user has not said which, ask them first — local runs on the connected agent, ' +
393
+ 'If the user has not said which, ask them first — local runs on a local agent, ' +
146
394
  'cloud runs on online cloud runner VM(s).');
147
395
  }
396
+ // Never assume now-vs-scheduled — it decides which agents are eligible (online-only for now,
397
+ // online+offline for a local schedule) and, for a schedule, the start time asked next.
398
+ if (!when) {
399
+ return err("Specify WHEN to run: call suite_run again with when: 'immediate' (run now) or when: 'schedule' " +
400
+ '(create a schedule). If the user has not said, ask them first — never assume.');
401
+ }
148
402
  let resolvedRunner;
149
403
  if (mode === 'cloud') {
150
404
  if (!adapter.listCloudRunners)
@@ -156,6 +410,7 @@ export function registerRunTools(server, deps) {
156
410
  catch (e) {
157
411
  return err(describeError(e));
158
412
  }
413
+ // Cloud is ONLINE-only whether now or scheduled (a schedule pin to an offline VM is rejected).
159
414
  const online = runners.filter((r) => r.online);
160
415
  if (!online.length) {
161
416
  return err('Currently there are no active cloud runners in your project.');
@@ -166,16 +421,67 @@ export function registerRunTools(server, deps) {
166
421
  return err(`Cloud runner "${cloudRunnerId}" is not online. Online now:\n` + online.map(runnerLine).join('\n'));
167
422
  }
168
423
  }
169
- else {
170
- resolvedRunner = online[0]; // auto-pick the first online runner
424
+ else if (when === 'immediate') {
425
+ resolvedRunner = online[0]; // run-now with no pick → auto-pick the first online runner
426
+ }
427
+ // when:'schedule' with no cloudRunnerId → leave unpinned; the backend auto-picks a live VM at fire time.
428
+ }
429
+ // LOCAL run-NOW to a specific agent must be online (can't dispatch to an offline agent). A local
430
+ // SCHEDULE may target an offline agent — that path is handled in the schedule branch below.
431
+ if (mode === 'local' && when === 'immediate' && agentId) {
432
+ const offlineMsg = await localAgentOfflineForNow(adapter, agentId);
433
+ if (offlineMsg)
434
+ return err(offlineMsg);
435
+ }
436
+ if (when === 'schedule') {
437
+ if (!adapter.createSchedule)
438
+ return err('This product does not support scheduling runs.');
439
+ if (!scheduledFor) {
440
+ return err("Scheduling needs a start time: call suite_run again with when: 'schedule' and scheduledFor as an " +
441
+ "ISO datetime (e.g. '2026-08-20T09:00:00Z'). Ask the user when the run should start (and the " +
442
+ 'recurrence, end date and timezone if any). NOTE: strategy/maxConcurrent/terminateOnFailure are ' +
443
+ "immediate-run only — a schedule uses the suite's own settings.");
444
+ }
445
+ // A schedule fires detached from any live session — a LOCAL schedule must pin a specific agent.
446
+ if (mode === 'local' && !agentId)
447
+ return err(await localScheduleNeedsAgent(adapter));
448
+ // Cloud: pin the runner's CREDENTIAL only when the user explicitly picked one; otherwise leave
449
+ // it unpinned so the backend auto-picks a live VM at fire time (don't freeze today's online[0]).
450
+ const pinnedRunner = mode === 'cloud' && cloudRunnerId ? resolvedRunner : undefined;
451
+ const schedOpts = {
452
+ mode,
453
+ scheduledFor,
454
+ ...(mode === 'local' && agentId ? { agentId } : {}),
455
+ ...(pinnedRunner?.credentialId ? { cloudRunnerCredentialId: pinnedRunner.credentialId } : {}),
456
+ ...(browser !== undefined ? { browser } : {}),
457
+ ...(environment !== undefined ? { environmentId: environment } : {}),
458
+ ...(releaseId ? { releaseId } : {}),
459
+ ...(sprintId ? { sprintId } : {}),
460
+ ...(scheduleType !== undefined ? { scheduleType } : {}),
461
+ ...(endDate !== undefined ? { endDate } : {}),
462
+ ...(timezone !== undefined ? { timezone } : {}),
463
+ };
464
+ try {
465
+ const sched = await adapter.createSchedule({ suiteId }, schedOpts);
466
+ const next = sched.nextRunAt ? ` · first run ${sched.nextRunAt}` : '';
467
+ const where = mode === 'cloud'
468
+ ? (pinnedRunner ? `cloud runner **${pinnedRunner.name}**` : 'cloud (auto-pick a live VM at run time)')
469
+ : `agent \`${agentId}\``;
470
+ return ok(`Scheduled suite ${suiteId} (${scheduleType ?? 'once'}, ${mode}) for ${scheduledFor}${next} → ${where}. Schedule id \`${sched.id}\`.`, { schedule: sched, mode, when: 'schedule', ...(pinnedRunner ? { runner: pinnedRunner } : {}) });
471
+ }
472
+ catch (e) {
473
+ return err(describeError(e));
171
474
  }
172
475
  }
173
476
  const opts = {
174
477
  mode,
478
+ ...(mode === 'local' && agentId ? { agentId } : {}),
175
479
  ...(environment !== undefined ? { environment } : {}),
176
480
  ...(browser !== undefined ? { browser } : {}),
177
481
  ...(headless !== undefined ? { headless } : {}),
178
482
  ...(resolvedRunner ? { cloudRunnerId: resolvedRunner.id } : {}),
483
+ ...(releaseId ? { releaseId } : {}),
484
+ ...(sprintId ? { sprintId } : {}),
179
485
  ...(strategy !== undefined ? { strategy } : {}),
180
486
  ...(maxConcurrent !== undefined ? { maxConcurrent } : {}),
181
487
  ...(terminateOnFailure !== undefined ? { terminateOnFailure } : {}),
@@ -184,7 +490,7 @@ export function registerRunTools(server, deps) {
184
490
  const run = await adapter.startRun({ suiteId }, opts);
185
491
  const where = mode === 'cloud'
186
492
  ? `on cloud runner ${resolvedRunner ? `**${resolvedRunner.name}**` : ''}`
187
- : 'locally on the connected agent';
493
+ : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
188
494
  return ok(`Started suite ${suiteId} ${where}. Run id \`${run.runId}\` — poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
189
495
  }
190
496
  catch (e) {
@@ -226,5 +532,124 @@ export function registerRunTools(server, deps) {
226
532
  return err(describeError(e));
227
533
  }
228
534
  });
535
+ server.registerTool('schedule_list', {
536
+ title: 'List schedules',
537
+ description: 'List the scheduled runs in the connected project — each with WHAT it runs (test case / suite), ' +
538
+ 'its cadence, next run time, status (scheduled / running / paused / cancelled), where it runs + the ' +
539
+ 'pinned agent, and its browser. Use this to find a schedule to edit, pause/resume, cancel, or delete. ' +
540
+ 'Requires a live connection.',
541
+ inputSchema: {},
542
+ annotations: { readOnlyHint: true },
543
+ }, async () => {
544
+ const adapter = deps.getAdapter();
545
+ if (!adapter)
546
+ return err(NOT_CONNECTED);
547
+ if (!adapter.listSchedules)
548
+ return err('This product does not support listing schedules.');
549
+ try {
550
+ const schedules = await adapter.listSchedules();
551
+ if (!schedules.length)
552
+ return ok('No schedules in this project.', { schedules: [] });
553
+ const text = `${schedules.length} schedule${schedules.length === 1 ? '' : 's'}:\n\n` +
554
+ schedules.map(scheduleLine).join('\n');
555
+ return ok(text, { schedules });
556
+ }
557
+ catch (e) {
558
+ return err(describeError(e));
559
+ }
560
+ });
561
+ server.registerTool('schedule_update', {
562
+ title: 'Edit a schedule',
563
+ description: "Change a schedule's time / recurrence / end date / timezone / browser / environment, or RE-PIN its " +
564
+ 'agent. Pass `scheduleId` (from schedule_list) and ONLY the fields to change. To re-pin: ' +
565
+ "mode:'local' + `agentId` (from local_agents), or mode:'cloud' + a runner's `cloudRunnerCredentialId` " +
566
+ '(the `credentialId` from cloud_runners). Requires a live connection.',
567
+ inputSchema: {
568
+ scheduleId: z.string().describe('The schedule id (from schedule_list).'),
569
+ scheduledFor: z.string().optional().describe('New start / next-run ISO datetime.'),
570
+ scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe('New recurrence.'),
571
+ endDate: z.string().optional().describe('New ISO end date (for recurring schedules).'),
572
+ timezone: z.string().optional().describe("New IANA timezone (e.g. 'Asia/Kolkata')."),
573
+ browser: z.enum(['chromium', 'msedge', 'firefox', 'webkit']).optional().describe('New browser (schedules accept chromium/msedge/firefox/webkit, not chrome).'),
574
+ environment: z.string().optional().describe('New environment id (Mongo id).'),
575
+ mode: z.enum(['local', 'cloud']).optional().describe('Re-pin target: pair with agentId (local) or cloudRunnerCredentialId (cloud).'),
576
+ agentId: z.string().optional().describe('Local re-pin: an agent credential_id from local_agents.'),
577
+ cloudRunnerCredentialId: z.string().optional().describe("Cloud re-pin: a runner's credentialId from cloud_runners."),
578
+ },
579
+ annotations: { readOnlyHint: false },
580
+ }, async ({ scheduleId, scheduledFor, scheduleType, endDate, timezone, browser, environment, mode, agentId, cloudRunnerCredentialId }) => {
581
+ const adapter = deps.getAdapter();
582
+ if (!adapter)
583
+ return err(NOT_CONNECTED);
584
+ if (!adapter.updateSchedule)
585
+ return err('This product does not support editing schedules.');
586
+ const changes = {
587
+ ...(scheduledFor !== undefined ? { scheduledFor } : {}),
588
+ ...(scheduleType !== undefined ? { scheduleType } : {}),
589
+ ...(endDate !== undefined ? { endDate } : {}),
590
+ ...(timezone !== undefined ? { timezone } : {}),
591
+ ...(browser !== undefined ? { browser } : {}),
592
+ ...(environment !== undefined ? { environmentId: environment } : {}),
593
+ ...(mode !== undefined ? { mode } : {}),
594
+ ...(agentId !== undefined ? { agentId } : {}),
595
+ ...(cloudRunnerCredentialId !== undefined ? { cloudRunnerCredentialId } : {}),
596
+ };
597
+ if (Object.keys(changes).length === 0) {
598
+ return err('Nothing to change — pass at least one field to edit (e.g. scheduledFor, scheduleType, browser, or a re-pin via mode + agentId/cloudRunnerCredentialId).');
599
+ }
600
+ try {
601
+ const ref = await adapter.updateSchedule(scheduleId, changes);
602
+ return ok(`Updated schedule \`${ref.id}\`${ref.nextRunAt ? ` · next run ${ref.nextRunAt}` : ''}.`, { schedule: ref });
603
+ }
604
+ catch (e) {
605
+ return err(describeError(e));
606
+ }
607
+ });
608
+ server.registerTool('schedule_control', {
609
+ title: 'Pause / resume / cancel a schedule',
610
+ description: "Change a schedule's state: `pause` (stop it firing but keep it — reversible with `resume`), " +
611
+ '`resume` (re-enable a paused schedule), or `cancel` (stop its pending / live run). To remove a ' +
612
+ 'schedule entirely, use schedule_delete instead. Requires a live connection.',
613
+ inputSchema: {
614
+ scheduleId: z.string().describe('The schedule id (from schedule_list).'),
615
+ action: z.enum(['pause', 'resume', 'cancel']).describe('pause / resume / cancel.'),
616
+ },
617
+ annotations: { readOnlyHint: false },
618
+ }, async ({ scheduleId, action }) => {
619
+ const adapter = deps.getAdapter();
620
+ if (!adapter)
621
+ return err(NOT_CONNECTED);
622
+ if (!adapter.controlSchedule)
623
+ return err('This product does not support pausing / resuming / cancelling schedules.');
624
+ try {
625
+ const ref = await adapter.controlSchedule(scheduleId, action);
626
+ const verb = action === 'pause' ? 'Paused' : action === 'resume' ? 'Resumed' : 'Cancelled';
627
+ return ok(`${verb} schedule \`${ref.id}\`${ref.status ? ` · status ${ref.status}` : ''}.`, { schedule: ref });
628
+ }
629
+ catch (e) {
630
+ return err(describeError(e));
631
+ }
632
+ });
633
+ server.registerTool('schedule_delete', {
634
+ title: 'Delete a schedule',
635
+ description: 'Permanently DELETE a schedule (and tear down any run it currently owns). This cannot be undone — ' +
636
+ 'to only stop it temporarily use schedule_control (pause / cancel). CONFIRM with the user before ' +
637
+ 'deleting. Requires a live connection.',
638
+ inputSchema: { scheduleId: z.string().describe('The schedule id (from schedule_list).') },
639
+ annotations: { readOnlyHint: false, destructiveHint: true },
640
+ }, async ({ scheduleId }) => {
641
+ const adapter = deps.getAdapter();
642
+ if (!adapter)
643
+ return err(NOT_CONNECTED);
644
+ if (!adapter.deleteSchedule)
645
+ return err('This product does not support deleting schedules.');
646
+ try {
647
+ await adapter.deleteSchedule(scheduleId);
648
+ return ok(`Deleted schedule \`${scheduleId}\`.`);
649
+ }
650
+ catch (e) {
651
+ return err(describeError(e));
652
+ }
653
+ });
229
654
  }
230
655
  //# sourceMappingURL=run.tools.js.map