@askqa/mcp 1.0.1

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 (2) hide show
  1. package/package.json +29 -0
  2. package/server.js +745 -0
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@askqa/mcp",
3
+ "version": "1.0.1",
4
+ "description": "MCP server for AskQA — monitor websites with automated tests via Claude and other AI assistants",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "askqa",
11
+ "testing",
12
+ "monitoring",
13
+ "playwright",
14
+ "claude"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "bin": {
20
+ "askqa-mcp": "./server.js"
21
+ },
22
+ "files": [
23
+ "server.js"
24
+ ],
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.0.0",
27
+ "zod": "^3.23.0"
28
+ }
29
+ }
package/server.js ADDED
@@ -0,0 +1,745 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+
7
+ const API_URL = (process.env.AUTOQA_API_URL || "http://localhost:8081").replace(/\/$/, "");
8
+ const API_KEY = process.env.AUTOQA_API_KEY || "";
9
+ const WEBSITE_URL = (process.env.AUTOQA_WEBSITE_URL || "http://localhost:8080").replace(/\/$/, "");
10
+
11
+ if (!API_KEY) {
12
+ console.error("AUTOQA_API_KEY is required. Set it in your MCP server config.");
13
+ process.exit(1);
14
+ }
15
+
16
+ async function apiGet(path) {
17
+ const res = await fetch(`${API_URL}${path}`, {
18
+ headers: { Authorization: `Bearer ${API_KEY}` },
19
+ });
20
+ if (!res.ok) {
21
+ const body = await res.text();
22
+ throw new Error(`API ${res.status}: ${body}`);
23
+ }
24
+ return res.json();
25
+ }
26
+
27
+ async function apiPost(path, body) {
28
+ const res = await fetch(`${API_URL}${path}`, {
29
+ method: "POST",
30
+ headers: {
31
+ Authorization: `Bearer ${API_KEY}`,
32
+ "Content-Type": "application/json",
33
+ },
34
+ body: JSON.stringify(body),
35
+ });
36
+ if (!res.ok) {
37
+ const text = await res.text();
38
+ throw new Error(`API ${res.status}: ${text}`);
39
+ }
40
+ return res.json();
41
+ }
42
+
43
+ async function apiPatch(path, body) {
44
+ const res = await fetch(`${API_URL}${path}`, {
45
+ method: "PATCH",
46
+ headers: {
47
+ Authorization: `Bearer ${API_KEY}`,
48
+ "Content-Type": "application/json",
49
+ },
50
+ body: JSON.stringify(body),
51
+ });
52
+ if (!res.ok) {
53
+ const text = await res.text();
54
+ throw new Error(`API ${res.status}: ${text}`);
55
+ }
56
+ return res.json();
57
+ }
58
+
59
+ async function apiDelete(path) {
60
+ const res = await fetch(`${API_URL}${path}`, {
61
+ method: "DELETE",
62
+ headers: { Authorization: `Bearer ${API_KEY}` },
63
+ });
64
+ if (!res.ok) {
65
+ const text = await res.text();
66
+ throw new Error(`API ${res.status}: ${text}`);
67
+ }
68
+ return res.json();
69
+ }
70
+
71
+ function sleep(ms) {
72
+ return new Promise((resolve) => setTimeout(resolve, ms));
73
+ }
74
+
75
+ async function pollTestRun(testRunId, maxWaitMs = 300000) {
76
+ const start = Date.now();
77
+ while (Date.now() - start < maxWaitMs) {
78
+ const testRun = await apiGet(`/api/test-runs/${testRunId}`);
79
+ if (testRun.status === "completed" || testRun.status === "failed") {
80
+ return testRun;
81
+ }
82
+ await sleep(2000);
83
+ }
84
+ throw new Error(`Test run ${testRunId} did not finish within ${maxWaitMs / 1000}s`);
85
+ }
86
+
87
+ async function fetchScreenshot(url) {
88
+ try {
89
+ const res = await fetch(url, {
90
+ headers: { Authorization: `Bearer ${API_KEY}` },
91
+ });
92
+ if (!res.ok) return null;
93
+ const buf = Buffer.from(await res.arrayBuffer());
94
+ return buf.toString("base64");
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function buildTestRunText(testRun) {
101
+ const icon = testRun.status === "completed" ? "✓" : testRun.status === "failed" ? "✗" : "…";
102
+ const lines = [
103
+ `${icon} Run #${testRun.id} | Test: ${testRun.test_id} | ${testRun.trigger_type} | ${testRun.status}`,
104
+ ];
105
+
106
+ if (testRun.result?.durationMs) {
107
+ lines.push(` Duration: ${(testRun.result.durationMs / 1000).toFixed(1)}s`);
108
+ } else if (testRun.started_at && testRun.completed_at) {
109
+ const duration = (new Date(testRun.completed_at) - new Date(testRun.started_at)) / 1000;
110
+ lines.push(` Duration: ${duration.toFixed(1)}s`);
111
+ }
112
+
113
+ if (testRun.completed_at) {
114
+ lines.push(` Completed: ${testRun.completed_at}`);
115
+ }
116
+
117
+ if (testRun.result?.steps) {
118
+ for (const step of testRun.result.steps) {
119
+ const icon = step.status === "passed" ? "✓" : step.status === "failed" ? "✗" : "?";
120
+ lines.push(` ${icon} ${step.name} — ${step.status}`);
121
+ if (step.details) lines.push(` ${step.details}`);
122
+ if (step.error) lines.push(` Error: ${step.error}`);
123
+ if (step.screenshot) lines.push(` Screenshot: ${step.screenshot}`);
124
+ }
125
+ }
126
+
127
+ if (testRun.result?.error) {
128
+ lines.push(` Error: ${testRun.result.error}`);
129
+ }
130
+ if (testRun.error) {
131
+ lines.push(` Error: ${testRun.error}`);
132
+ }
133
+
134
+ const hasScreenshots = testRun.result?.steps?.some((s) => s.screenshot);
135
+ if (hasScreenshots) {
136
+ lines.push(` Use get_test_screenshots with test_run_id ${testRun.id} to view screenshots.`);
137
+ }
138
+
139
+ lines.push(` View details: ${testRun.details_url || `${WEBSITE_URL}/runs/${testRun.id}`}`);
140
+
141
+ return lines.join("\n");
142
+ }
143
+
144
+ async function buildTestRunScreenshots(testRun) {
145
+ const content = [];
146
+ if (!testRun.result?.steps) return content;
147
+
148
+ for (const step of testRun.result.steps) {
149
+ if (!step.screenshot) continue;
150
+ const stepName = step.screenshot.replace(/\.png$/, "");
151
+ const url = `${API_URL}/api/test-runs/${testRun.id}/screenshots/${stepName}`;
152
+ const base64 = await fetchScreenshot(url);
153
+ if (base64) {
154
+ content.push({ type: "image", data: base64, mimeType: "image/png" });
155
+ }
156
+ }
157
+ return content;
158
+ }
159
+
160
+ const server = new McpServer(
161
+ {
162
+ name: "askqa",
163
+ version: "1.0.0",
164
+ },
165
+ {
166
+ instructions: [
167
+ "AskQA monitors websites by running automated tests on a schedule.",
168
+ "",
169
+ 'When the user asks whether something is working (e.g. "is checkout working?", "is the site up?"),',
170
+ "your FIRST step should be to call list_tests to find a matching test by name or URL,",
171
+ "then call get_test_results for that test to check the latest run status and step details.",
172
+ "If the latest run passed, confirm it\'s working. If it failed, report what failed.",
173
+ "Only call run_test if the user explicitly asks to run a new test — checking status should use existing results.",
174
+ ].join("\n"),
175
+ }
176
+ );
177
+
178
+ server.registerTool(
179
+ "list_templates",
180
+ {
181
+ description: "List available test templates. Returns template IDs, names, descriptions, and steps.",
182
+ },
183
+ async () => {
184
+ try {
185
+ const data = await apiGet("/api/tests/templates");
186
+ return { content: [{ type: "text", text: JSON.stringify(data.templates, null, 2) }] };
187
+ } catch (err) {
188
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
189
+ }
190
+ }
191
+ );
192
+
193
+ server.registerTool(
194
+ "create_test",
195
+ {
196
+ description: "Create a saved test. Use template_id for built-in templates, or code for custom Playwright tests. Provide one or the other, not both.",
197
+ inputSchema: {
198
+ name: z.string().describe("A name for this test (e.g. 'Homepage health check')"),
199
+ url: z.string().describe("The target URL to test (e.g. 'https://example.com')"),
200
+ template_id: z.string().optional().describe("Template ID from list_templates (e.g. 'quick-checks'). Omit if using code."),
201
+ params: z.record(z.string()).optional().describe("Optional template parameters"),
202
+ code: z.string().optional().describe("Custom Playwright test code. Must define an async function test({ page, step, log }). Omit if using template_id."),
203
+ },
204
+ },
205
+ async ({ name, url, template_id, params, code }) => {
206
+ try {
207
+ const body = { name, url };
208
+ if (template_id) body.template_id = template_id;
209
+ if (params) body.params = params;
210
+ if (code) body.code = code;
211
+ const test = await apiPost("/api/tests/create", body);
212
+ return { content: [{ type: "text", text: JSON.stringify(test, null, 2) }] };
213
+ } catch (err) {
214
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
215
+ }
216
+ }
217
+ );
218
+
219
+ server.registerTool(
220
+ "screenshot_url",
221
+ {
222
+ description: "Take a screenshot of a URL and extract page structure (links, buttons, inputs, headings with selectors). Use this BEFORE writing custom test code to see the page layout and discover available selectors.",
223
+ inputSchema: {
224
+ url: z.string().describe("The URL to screenshot (e.g. 'https://example.com')"),
225
+ },
226
+ },
227
+ async ({ url }) => {
228
+ try {
229
+ const result = await apiPost("/api/tests/screenshot", { url });
230
+ const content = [];
231
+
232
+ // Page info as structured text
233
+ const info = result.pageInfo || {};
234
+ const lines = [`Page: ${info.title || "(no title)"}`, `URL: ${info.url || url}`, ""];
235
+
236
+ if (info.headings?.length) {
237
+ lines.push("Headings:");
238
+ for (const h of info.headings) lines.push(` <${h.tag}> ${h.text}`);
239
+ lines.push("");
240
+ }
241
+ if (info.buttons?.length) {
242
+ lines.push("Buttons:");
243
+ for (const b of info.buttons) {
244
+ const extra = b.disabled ? " (disabled)" : "";
245
+ lines.push(` "${b.text}"${extra} → ${b.selector}`);
246
+ }
247
+ lines.push("");
248
+ }
249
+ if (info.inputs?.length) {
250
+ lines.push("Inputs:");
251
+ for (const inp of info.inputs) {
252
+ const desc = inp.placeholder || inp.name || inp.type || inp.tag;
253
+ lines.push(` [${desc}] → ${inp.selector}`);
254
+ }
255
+ lines.push("");
256
+ }
257
+ if (info.links?.length) {
258
+ lines.push("Links:");
259
+ for (const l of info.links) {
260
+ lines.push(` "${l.text}" → ${l.selector}`);
261
+ }
262
+ lines.push("");
263
+ }
264
+
265
+ content.push({ type: "text", text: lines.join("\n") });
266
+
267
+ // Screenshot
268
+ if (result.screenshot) {
269
+ content.push({ type: "image", data: result.screenshot, mimeType: "image/png" });
270
+ }
271
+
272
+ return { content };
273
+ } catch (err) {
274
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
275
+ }
276
+ }
277
+ );
278
+
279
+ server.registerTool(
280
+ "validate_test",
281
+ {
282
+ description: "Dry-run custom Playwright test code against a URL. Returns execution results, screenshots, and page structure for debugging. Steps continue even on failure to maximize debug signal. Use this to iterate on code before calling create_test.",
283
+ inputSchema: {
284
+ code: z.string().describe("Custom Playwright test code. Must define an async function test({ page, step, log })."),
285
+ url: z.string().describe("The target URL to test against (e.g. 'https://example.com')"),
286
+ },
287
+ },
288
+ async ({ code, url }) => {
289
+ try {
290
+ const result = await apiPost("/api/tests/validate", { code, url });
291
+ const content = [];
292
+
293
+ // Build text summary
294
+ const icon = result.status === "passed" ? "✓" : "✗";
295
+ const lines = [`${icon} Validation: ${result.status}`];
296
+ if (result.durationMs) lines.push(` Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
297
+ if (result.error) lines.push(` Error: ${result.error}`);
298
+ for (const step of (result.steps || [])) {
299
+ const stepIcon = step.status === "passed" ? "✓" : step.status === "failed" ? "✗" : "?";
300
+ lines.push(` ${stepIcon} ${step.name} — ${step.status}`);
301
+ if (step.error) lines.push(` Error: ${step.error}`);
302
+ }
303
+ if (result.logs?.length) {
304
+ lines.push(" Logs:");
305
+ for (const msg of result.logs) lines.push(` ${msg}`);
306
+ }
307
+ content.push({ type: "text", text: lines.join("\n") });
308
+
309
+ // Include page info for debugging selectors
310
+ if (result.pageInfo) {
311
+ const info = result.pageInfo;
312
+ const infoLines = ["", "Page structure (for fixing selectors):"];
313
+ if (info.buttons?.length) {
314
+ infoLines.push(" Buttons:");
315
+ for (const b of info.buttons) infoLines.push(` "${b.text}" → ${b.selector}`);
316
+ }
317
+ if (info.inputs?.length) {
318
+ infoLines.push(" Inputs:");
319
+ for (const inp of info.inputs) {
320
+ const desc = inp.placeholder || inp.name || inp.type || inp.tag;
321
+ infoLines.push(` [${desc}] → ${inp.selector}`);
322
+ }
323
+ }
324
+ if (info.links?.length) {
325
+ infoLines.push(" Links:");
326
+ for (const l of info.links) infoLines.push(` "${l.text}" → ${l.selector}`);
327
+ }
328
+ content.push({ type: "text", text: infoLines.join("\n") });
329
+ }
330
+
331
+ // Include screenshots as labeled images
332
+ if (result.screenshots) {
333
+ for (const [stepName, base64] of Object.entries(result.screenshots)) {
334
+ if (base64) {
335
+ content.push({ type: "text", text: `Screenshot: ${stepName}` });
336
+ content.push({ type: "image", data: base64, mimeType: "image/png" });
337
+ }
338
+ }
339
+ }
340
+
341
+ return { content };
342
+ } catch (err) {
343
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
344
+ }
345
+ }
346
+ );
347
+
348
+ server.registerTool(
349
+ "list_tests",
350
+ {
351
+ description: "List all saved tests for the current organization. Returns id, name, url, template_id, status, and last_run summary (id, status, completed_at). This is the best starting point when checking if a feature or site is working — find the relevant test, then use get_test_results to see details.",
352
+ },
353
+ async () => {
354
+ try {
355
+ const data = await apiGet("/api/tests/list");
356
+ return { content: [{ type: "text", text: JSON.stringify(data.tests, null, 2) }] };
357
+ } catch (err) {
358
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
359
+ }
360
+ }
361
+ );
362
+
363
+ server.registerTool(
364
+ "get_test",
365
+ {
366
+ description: "Get full details of a test by ID, including code for custom tests. Use list_tests to find the test ID first.",
367
+ inputSchema: {
368
+ test_id: z.coerce.number().describe("The test ID (from list_tests or create_test)"),
369
+ },
370
+ },
371
+ async ({ test_id }) => {
372
+ try {
373
+ const test = await apiGet(`/api/tests/${test_id}`);
374
+ return { content: [{ type: "text", text: JSON.stringify(test, null, 2) }] };
375
+ } catch (err) {
376
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
377
+ }
378
+ }
379
+ );
380
+
381
+ server.registerTool(
382
+ "update_test",
383
+ {
384
+ description: "Update an existing test's name, URL, code, or other properties. Only provided fields are changed.",
385
+ inputSchema: {
386
+ test_id: z.coerce.number().describe("The test ID to update (from list_tests or get_test)"),
387
+ name: z.string().optional().describe("New test name"),
388
+ url: z.string().optional().describe("New target URL"),
389
+ code: z.string().optional().describe("Updated custom Playwright test code"),
390
+ template_id: z.string().optional().describe("Updated template ID"),
391
+ params: z.record(z.string()).optional().describe("Updated template parameters"),
392
+ },
393
+ },
394
+ async ({ test_id, name, url, code, template_id, params }) => {
395
+ try {
396
+ const body = {};
397
+ if (name !== undefined) body.name = name;
398
+ if (url !== undefined) body.url = url;
399
+ if (code !== undefined) body.code = code;
400
+ if (template_id !== undefined) body.template_id = template_id;
401
+ if (params !== undefined) body.params = params;
402
+ const test = await apiPatch(`/api/tests/${test_id}`, body);
403
+ return { content: [{ type: "text", text: JSON.stringify(test, null, 2) }] };
404
+ } catch (err) {
405
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
406
+ }
407
+ }
408
+ );
409
+
410
+ server.registerTool(
411
+ "delete_test",
412
+ {
413
+ description: "Permanently delete a test and its associated schedules. IMPORTANT: Always call this first WITHOUT confirm to see what will be deleted, show that to the user, and only call again with confirm=true after the user explicitly agrees.",
414
+ inputSchema: {
415
+ test_id: z.coerce.number().describe("The test ID to delete (from list_tests)"),
416
+ confirm: z.boolean().optional().describe("Set to true to actually delete. Omit or false to preview what will be deleted."),
417
+ },
418
+ },
419
+ async ({ test_id, confirm }) => {
420
+ try {
421
+ // Always fetch test info first
422
+ const test = await apiGet(`/api/tests/${test_id}`);
423
+ const schedulesData = await apiGet("/api/schedules");
424
+ const testSchedules = schedulesData.schedules.filter((s) => s.test_id === test_id);
425
+
426
+ if (!confirm) {
427
+ // Preview mode — show what will be deleted
428
+ const lines = [
429
+ `⚠ About to delete:`,
430
+ ` Test: "${test.name}" (ID: ${test_id})`,
431
+ ` URL: ${test.url}`,
432
+ ];
433
+ if (testSchedules.length) {
434
+ lines.push(` Schedules that will also be deleted: ${testSchedules.length}`);
435
+ for (const s of testSchedules) {
436
+ lines.push(` - Schedule #${s.id} (${s.interval}, ${s.enabled ? "enabled" : "paused"})`);
437
+ }
438
+ } else {
439
+ lines.push(" No associated schedules.");
440
+ }
441
+ lines.push("", "Ask the user to confirm, then call delete_test again with confirm=true.");
442
+ return { content: [{ type: "text", text: lines.join("\n") }] };
443
+ }
444
+
445
+ // Confirmed — delete
446
+ const result = await apiDelete(`/api/tests/${test_id}`);
447
+ const lines = [
448
+ `✓ Deleted test "${result.test_name}" (ID: ${result.test_id})`,
449
+ ];
450
+ if (result.schedules_deleted > 0) {
451
+ lines.push(` Also deleted ${result.schedules_deleted} associated schedule(s).`);
452
+ }
453
+ return { content: [{ type: "text", text: lines.join("\n") }] };
454
+ } catch (err) {
455
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
456
+ }
457
+ }
458
+ );
459
+
460
+ server.registerTool(
461
+ "run_test",
462
+ {
463
+ description: "Run a saved test by ID. Waits for the test to finish and returns full results with step details.",
464
+ inputSchema: {
465
+ test_id: z.coerce.number().describe("The test ID to run (from create_test or list_tests)"),
466
+ },
467
+ },
468
+ async ({ test_id }) => {
469
+ try {
470
+ const { test_run_id } = await apiPost(`/api/tests/${test_id}/run`, {});
471
+ console.error(`Started test run ${test_run_id} for test ${test_id}`);
472
+ const testRun = await pollTestRun(test_run_id);
473
+ const text = buildTestRunText(testRun);
474
+ return { content: [{ type: "text", text }] };
475
+ } catch (err) {
476
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
477
+ }
478
+ }
479
+ );
480
+
481
+ server.registerTool(
482
+ "get_test_screenshots",
483
+ {
484
+ description: "Get screenshots from a test run. Returns only images. Use after run_test or get_test_results to view screenshots.",
485
+ inputSchema: {
486
+ test_run_id: z.coerce.number().describe("The test run ID (from run_test or get_test_results)"),
487
+ },
488
+ },
489
+ async ({ test_run_id }) => {
490
+ try {
491
+ const testRun = await apiGet(`/api/test-runs/${test_run_id}`);
492
+ if (!testRun.execution_id) {
493
+ return { content: [{ type: "text", text: "No screenshots available for this test run." }] };
494
+ }
495
+ const content = await buildTestRunScreenshots(testRun);
496
+ if (!content.length) {
497
+ return { content: [{ type: "text", text: "No screenshots found for this test run." }] };
498
+ }
499
+ return { content };
500
+ } catch (err) {
501
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
502
+ }
503
+ }
504
+ );
505
+
506
+ server.registerTool(
507
+ "schedule_test",
508
+ {
509
+ description: "Create a recurring schedule for a saved test. The test will run automatically at the specified interval.",
510
+ inputSchema: {
511
+ test_id: z.coerce.number().describe("The test ID to schedule (from create_test or list_tests)"),
512
+ interval: z.enum(["every_minute", "hourly", "every_6_hours", "every_12_hours", "daily", "weekly"])
513
+ .describe("How often to run the test"),
514
+ },
515
+ },
516
+ async ({ test_id, interval }) => {
517
+ try {
518
+ const schedule = await apiPost("/api/schedules", { test_id, interval });
519
+ const lines = [
520
+ `Schedule created (ID: ${schedule.id})`,
521
+ ` Test: ${schedule.test_name || schedule.test_id}`,
522
+ ` Interval: ${schedule.interval}`,
523
+ ` Enabled: ${schedule.enabled}`,
524
+ ` Next run: ${schedule.next_run_at}`,
525
+ ];
526
+ return { content: [{ type: "text", text: lines.join("\n") }] };
527
+ } catch (err) {
528
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
529
+ }
530
+ }
531
+ );
532
+
533
+ server.registerTool(
534
+ "list_schedules",
535
+ {
536
+ description: "List all test schedules for the current organization, including last run status and next run time.",
537
+ },
538
+ async () => {
539
+ try {
540
+ const data = await apiGet("/api/schedules");
541
+ if (!data.schedules.length) {
542
+ return { content: [{ type: "text", text: "No schedules found." }] };
543
+ }
544
+ const lines = [];
545
+ for (const s of data.schedules) {
546
+ const status = s.enabled ? "enabled" : "paused";
547
+ const testLabel = s.test_name ? `${s.test_name} (#${s.test_id})` : `#${s.test_id}`;
548
+ lines.push(`ID: ${s.id} | Test: ${testLabel} | ${s.interval} (${status})`);
549
+ lines.push(` Next run: ${s.next_run_at || "—"}`);
550
+ if (s.last_run) {
551
+ lines.push(` Last run: #${s.last_run.id} — ${s.last_run.status} (${s.last_run.completed_at || "in progress"})`);
552
+ }
553
+ lines.push("");
554
+ }
555
+ return { content: [{ type: "text", text: lines.join("\n") }] };
556
+ } catch (err) {
557
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
558
+ }
559
+ }
560
+ );
561
+
562
+ server.registerTool(
563
+ "update_schedule",
564
+ {
565
+ description: "Pause or resume a test schedule.",
566
+ inputSchema: {
567
+ schedule_id: z.coerce.number().describe("The schedule ID (from list_schedules)"),
568
+ enabled: z.boolean().describe("true to resume, false to pause"),
569
+ },
570
+ },
571
+ async ({ schedule_id, enabled }) => {
572
+ try {
573
+ const schedule = await apiPatch(`/api/schedules/${schedule_id}`, { enabled });
574
+ const action = schedule.enabled ? "resumed" : "paused";
575
+ const testLabel = schedule.test_name ? `${schedule.test_name} (#${schedule.test_id})` : `#${schedule.test_id}`;
576
+ const lines = [`Schedule ${schedule_id} ${action} (${testLabel}).`];
577
+ if (schedule.enabled) {
578
+ lines.push(` Next run: ${schedule.next_run_at}`);
579
+ }
580
+ return { content: [{ type: "text", text: lines.join("\n") }] };
581
+ } catch (err) {
582
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
583
+ }
584
+ }
585
+ );
586
+
587
+ server.registerTool(
588
+ "delete_schedule",
589
+ {
590
+ description: "Permanently delete a test schedule. Historical run results are preserved.",
591
+ inputSchema: {
592
+ schedule_id: z.coerce.number().describe("The schedule ID to delete (from list_schedules)"),
593
+ },
594
+ },
595
+ async ({ schedule_id }) => {
596
+ try {
597
+ await apiDelete(`/api/schedules/${schedule_id}`);
598
+ return { content: [{ type: "text", text: `Schedule ${schedule_id} deleted.` }] };
599
+ } catch (err) {
600
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
601
+ }
602
+ }
603
+ );
604
+
605
+ server.registerTool(
606
+ "get_test_results",
607
+ {
608
+ description: "Get recent test run results with step-by-step details. Use this to answer questions like 'is X working?' — filter by test_id to see the latest runs for a specific test. Shows status, timing, step pass/fail, errors, and screenshot links.",
609
+ inputSchema: {
610
+ test_id: z.coerce.number().optional().describe("Filter by test ID (optional — omit to see all runs)"),
611
+ limit: z.coerce.number().optional().describe("Max results to return (default: 10)"),
612
+ },
613
+ },
614
+ async ({ test_id, limit }) => {
615
+ try {
616
+ const params = new URLSearchParams();
617
+ if (test_id) params.set("test_id", String(test_id));
618
+ params.set("limit", String(limit || 10));
619
+ const data = await apiGet(`/api/test-runs?${params}`);
620
+ if (!data.test_runs.length) {
621
+ return { content: [{ type: "text", text: "No test runs found." }] };
622
+ }
623
+ const lines = [];
624
+ for (const run of data.test_runs) {
625
+ lines.push(buildTestRunText(run));
626
+ lines.push("");
627
+ }
628
+ return { content: [{ type: "text", text: lines.join("\n") }] };
629
+ } catch (err) {
630
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
631
+ }
632
+ }
633
+ );
634
+
635
+ // --- Notification channel tools ---
636
+
637
+ server.registerTool(
638
+ "add_notification_channel",
639
+ {
640
+ description: "Add a notification channel to receive alerts when scheduled tests fail. Currently supports Telegram. Notifications are sent from @AskQA_Notifications_bot. To get a Telegram chat ID: open https://t.me/userinfobot and press Start — it replies with your ID.",
641
+ inputSchema: {
642
+ channel_type: z.enum(["telegram"]).describe("The notification channel type"),
643
+ chat_id: z.string().describe("Telegram chat ID. Get it from https://t.me/userinfobot (open the link, press Start, it replies with your ID)."),
644
+ },
645
+ },
646
+ async ({ channel_type, chat_id }) => {
647
+ try {
648
+ const config = {};
649
+ if (channel_type === "telegram") {
650
+ config.chat_id = chat_id;
651
+ }
652
+ const channel = await apiPost("/api/notification-channels", {
653
+ channel_type,
654
+ config,
655
+ });
656
+ const lines = [
657
+ `Notification channel created (ID: ${channel.id})`,
658
+ ` Type: ${channel.channel_type}`,
659
+ ` Chat ID: ${channel.config.chat_id}`,
660
+ ` Enabled: ${channel.enabled}`,
661
+ "",
662
+ "Sending a test notification to verify the channel works...",
663
+ ].filter(Boolean);
664
+
665
+ // Send test notification
666
+ try {
667
+ await apiPost(`/api/notification-channels/${channel.id}/test`, {});
668
+ lines.push("✓ Test notification sent successfully!");
669
+ } catch (testErr) {
670
+ lines.push(`⚠ Could not send test notification: ${testErr.message}`);
671
+ }
672
+
673
+ return { content: [{ type: "text", text: lines.join("\n") }] };
674
+ } catch (err) {
675
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
676
+ }
677
+ }
678
+ );
679
+
680
+ server.registerTool(
681
+ "list_notification_channels",
682
+ {
683
+ description: "List all notification channels configured for the current organization.",
684
+ },
685
+ async () => {
686
+ try {
687
+ const data = await apiGet("/api/notification-channels");
688
+ if (!data.channels.length) {
689
+ return { content: [{ type: "text", text: "No notification channels configured. Use add_notification_channel to set one up." }] };
690
+ }
691
+ const lines = [];
692
+ for (const ch of data.channels) {
693
+ const status = ch.enabled ? "enabled" : "disabled";
694
+ lines.push(`ID: ${ch.id} | ${ch.channel_type} (${status})`);
695
+ if (ch.channel_type === "telegram") {
696
+ lines.push(` Chat ID: ${ch.config.chat_id}`);
697
+ }
698
+ lines.push("");
699
+ }
700
+ return { content: [{ type: "text", text: lines.join("\n") }] };
701
+ } catch (err) {
702
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
703
+ }
704
+ }
705
+ );
706
+
707
+ server.registerTool(
708
+ "remove_notification_channel",
709
+ {
710
+ description: "Remove a notification channel. Use list_notification_channels to find the channel ID.",
711
+ inputSchema: {
712
+ channel_id: z.coerce.number().describe("The channel ID to remove (from list_notification_channels)"),
713
+ },
714
+ },
715
+ async ({ channel_id }) => {
716
+ try {
717
+ await apiDelete(`/api/notification-channels/${channel_id}`);
718
+ return { content: [{ type: "text", text: `Notification channel ${channel_id} removed.` }] };
719
+ } catch (err) {
720
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
721
+ }
722
+ }
723
+ );
724
+
725
+ server.registerTool(
726
+ "test_notification_channel",
727
+ {
728
+ description: "Send a test notification to verify a channel is working correctly.",
729
+ inputSchema: {
730
+ channel_id: z.coerce.number().describe("The channel ID to test (from list_notification_channels)"),
731
+ },
732
+ },
733
+ async ({ channel_id }) => {
734
+ try {
735
+ await apiPost(`/api/notification-channels/${channel_id}/test`, {});
736
+ return { content: [{ type: "text", text: `Test notification sent to channel ${channel_id}.` }] };
737
+ } catch (err) {
738
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
739
+ }
740
+ }
741
+ );
742
+
743
+ const transport = new StdioServerTransport();
744
+ await server.connect(transport);
745
+ console.error("AskQA MCP server running on stdio");