@askqa/mcp 1.1.2 → 1.2.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askqa",
3
- "version": "1.1.2",
3
+ "version": "1.2.2",
4
4
  "description": "AskQA skills — set up notifications and monitoring for your websites",
5
5
  "mcpServers": {
6
6
  "askqa": {
package/README.md CHANGED
@@ -41,6 +41,7 @@ Get your API key from [askqa.ai](https://askqa.ai) after signing in.
41
41
  | `delete_test` | Delete a test (with confirmation) |
42
42
  | `run_test` | Run a test and wait for results |
43
43
  | `get_test_results` | Get recent test run results with step details |
44
+ | `heal_test` | Diagnose a layout-change regression and help fix selectors |
44
45
  | `get_test_screenshots` | Get screenshots from a test run |
45
46
  | `list_templates` | List available test templates |
46
47
  | `screenshot_url` | Screenshot a URL and extract page structure |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askqa/mcp",
3
- "version": "1.1.2",
3
+ "version": "1.2.2",
4
4
  "description": "MCP server for AskQA — monitor websites with automated tests by chatting with AI",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/server.js CHANGED
@@ -138,6 +138,9 @@ function buildTestRunText(testRun, testName) {
138
138
  }
139
139
 
140
140
  lines.push(` View details: ${testRun.details_url || `${WEBSITE_URL}/runs/${testRun.id}`}`);
141
+ if (testRun.trace_viewer_url) {
142
+ lines.push(` View trace: ${testRun.trace_viewer_url}`);
143
+ }
141
144
 
142
145
  return lines.join("\n");
143
146
  }
@@ -381,7 +384,7 @@ server.registerTool(
381
384
  server.registerTool(
382
385
  "validate_test",
383
386
  {
384
- 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.",
387
+ description: "Start here when writing a new test. Dry-runs Playwright code against a URL without saving it — returns step results, screenshots, and page structure. Steps continue even on failure for maximum debug signal. Iterate here until the test passes, then call create_test to save it.",
385
388
  readOnlyHint: true,
386
389
  inputSchema: {
387
390
  code: z.string().describe("Custom Playwright test code. Must define an async function test({ page, step, log })."),
@@ -390,28 +393,33 @@ server.registerTool(
390
393
  },
391
394
  async ({ code, url }) => {
392
395
  try {
393
- const result = await apiPost("/api/tests/validate", { code, url });
396
+ // POST returns immediately with test_run_id; poll until done (same as run_test)
397
+ const { test_run_id } = await apiPost("/api/tests/validate", { code, url });
398
+ const testRun = await pollTestRun(test_run_id);
399
+ const runResult = testRun.result || {};
394
400
  const content = [];
395
401
 
396
402
  // Build text summary
397
- const icon = result.status === "passed" ? "" : "";
398
- const lines = [`${icon} Validation: ${result.status}`];
399
- if (result.durationMs) lines.push(` Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
400
- if (result.error) lines.push(` Error: ${result.error}`);
401
- for (const step of (result.steps || [])) {
403
+ const overallStatus = runResult.status || (testRun.status === "completed" ? "passed" : "failed");
404
+ const icon = overallStatus === "passed" ? "✓" : "✗";
405
+ const lines = [`${icon} Validation: ${overallStatus}`];
406
+ if (runResult.durationMs) lines.push(` Duration: ${(runResult.durationMs / 1000).toFixed(1)}s`);
407
+ if (testRun.error) lines.push(` Error: ${testRun.error}`);
408
+ if (runResult.error) lines.push(` Error: ${runResult.error}`);
409
+ for (const step of (runResult.steps || [])) {
402
410
  const stepIcon = step.status === "passed" ? "✓" : step.status === "failed" ? "✗" : "?";
403
411
  lines.push(` ${stepIcon} ${step.name} — ${step.status}`);
404
412
  if (step.error) lines.push(` Error: ${step.error}`);
405
413
  }
406
- if (result.logs?.length) {
414
+ if (runResult.logs?.length) {
407
415
  lines.push(" Logs:");
408
- for (const msg of result.logs) lines.push(` ${msg}`);
416
+ for (const msg of runResult.logs) lines.push(` ${msg}`);
409
417
  }
410
418
  content.push({ type: "text", text: lines.join("\n") });
411
419
 
412
420
  // Include page info for debugging selectors
413
- if (result.pageInfo) {
414
- const info = result.pageInfo;
421
+ if (runResult.pageInfo) {
422
+ const info = runResult.pageInfo;
415
423
  const infoLines = ["", "Page structure (for fixing selectors):"];
416
424
  if (info.buttons?.length) {
417
425
  infoLines.push(" Buttons:");
@@ -431,14 +439,10 @@ server.registerTool(
431
439
  content.push({ type: "text", text: infoLines.join("\n") });
432
440
  }
433
441
 
434
- // Include screenshots as labeled images
435
- if (result.screenshots) {
436
- for (const [stepName, base64] of Object.entries(result.screenshots)) {
437
- if (base64) {
438
- content.push({ type: "text", text: `Screenshot: ${stepName}` });
439
- content.push({ type: "image", data: base64, mimeType: "image/png" });
440
- }
441
- }
442
+ // Include screenshots from storage (via execution_id)
443
+ if (testRun.execution_id) {
444
+ const screenshots = await buildTestRunScreenshots(testRun);
445
+ content.push(...screenshots);
442
446
  }
443
447
 
444
448
  return { content };
@@ -498,9 +502,11 @@ server.registerTool(
498
502
  secrets: z.record(z.string()).nullable().optional().describe("Updated secrets (pass null to clear). Encrypted at rest, never returned in API responses — must be provided again when updating a test that uses secrets."),
499
503
  headers: z.record(z.string()).nullable().optional().describe("Updated HTTP headers (pass null to clear)"),
500
504
  enable_test_mode: z.boolean().optional().describe("Send X-AskQA-Secret header to the target site, enabling test mode on sites that support it (default: true)"),
505
+ healing_disabled: z.boolean().optional().describe("Set true to stop AskQA from suggesting fixes for this test (e.g. a failing test that's acceptable as-is, or one you'd rather fix yourself). Auto-clears once the test passes again. Set false to re-enable."),
506
+ healing_note: z.string().nullable().optional().describe("Optional note explaining why healing was disabled (pass null to clear)."),
501
507
  },
502
508
  },
503
- async ({ test_id, name, url, code, template_id, params, secrets, headers, enable_test_mode }) => {
509
+ async ({ test_id, name, url, code, template_id, params, secrets, headers, enable_test_mode, healing_disabled, healing_note }) => {
504
510
  try {
505
511
  const body = {};
506
512
  if (name !== undefined) body.name = name;
@@ -511,6 +517,8 @@ server.registerTool(
511
517
  if (secrets !== undefined) body.secrets = secrets;
512
518
  if (headers !== undefined) body.headers = headers;
513
519
  if (enable_test_mode !== undefined) body.enable_test_mode = enable_test_mode;
520
+ if (healing_disabled !== undefined) body.healing_disabled = healing_disabled;
521
+ if (healing_note !== undefined) body.healing_note = healing_note;
514
522
  const test = await apiPatch(`/api/tests/${test_id}`, body);
515
523
  return { content: [{ type: "text", text: JSON.stringify(test, null, 2) }] };
516
524
  } catch (err) {
@@ -762,6 +770,150 @@ server.registerTool(
762
770
  }
763
771
  );
764
772
 
773
+ const REASON_EXPLANATIONS = {
774
+ "healing-disabled": "Healing is turned off for this test. Re-enable with update_test healing_disabled=false, or pass force=true to heal anyway.",
775
+ "not-custom-code": "This is a template-based test with no custom selectors to heal. Layout-change healing only applies to custom-code tests.",
776
+ "currently-passing": "The test's most recent run passed — there's nothing to heal.",
777
+ "not-enough-failures": "The test hasn't failed enough consecutive times yet to confirm a regression (a single/transient failure isn't healed). Wait for the confirmation threshold.",
778
+ "never-passed": "This test has no prior passing run, so a failure isn't a regression — it likely needs to be authored/fixed from scratch rather than healed.",
779
+ "transient-recovered": "On a fresh re-run the test passed, so the earlier failure was transient — no layout fix needed.",
780
+ };
781
+
782
+ function renderPageStructure(pageInfo) {
783
+ if (!pageInfo) return "";
784
+ const lines = ["", "Current page structure (selectors that exist NOW — map old selectors to these):"];
785
+ if (pageInfo.buttons?.length) {
786
+ lines.push(" Buttons:");
787
+ for (const b of pageInfo.buttons) lines.push(` "${b.text}" → ${b.selector}`);
788
+ }
789
+ if (pageInfo.inputs?.length) {
790
+ lines.push(" Inputs:");
791
+ for (const inp of pageInfo.inputs) {
792
+ const desc = inp.placeholder || inp.name || inp.type || inp.tag;
793
+ lines.push(` [${desc}] → ${inp.selector}`);
794
+ }
795
+ }
796
+ if (pageInfo.links?.length) {
797
+ lines.push(" Links:");
798
+ for (const l of pageInfo.links) lines.push(` "${l.text}" → ${l.selector}`);
799
+ }
800
+ return lines.join("\n");
801
+ }
802
+
803
+ server.registerTool(
804
+ "heal_test",
805
+ {
806
+ description: "Diagnose and help heal a test that recently started failing because the website's layout changed (e.g. an input id or button selector changed). Accepts a test_id or name. Checks run history to confirm a real regression (was passing, now consistently failing) and that the failure is selector/layout-related — NOT a site outage, network error, or genuine feature breakage. When healable, it re-runs the test live, captures the CURRENT page structure + screenshots, and returns a brief for you to write new selectors. IMPORTANT: only fix the selectors — never change the test's logic, step order, assertions, or navigation, and never skip steps. After proposing new code, verify it with validate_test, and only then apply with update_test. If the failing test is actually acceptable, or the user wants to fix it themselves, call update_test healing_disabled=true to stop future suggestions.",
807
+ readOnlyHint: true,
808
+ inputSchema: {
809
+ test_id: z.coerce.number().optional().describe("The test ID to heal (from list_tests). Provide this or name."),
810
+ name: z.string().optional().describe("Test name (case-insensitive substring match) if you don't have the ID."),
811
+ reproduce: z.boolean().optional().describe("Re-run the test live to confirm the failure and capture current layout (default: true)."),
812
+ force: z.boolean().optional().describe("Heal even if healing_disabled is set on the test (default: false)."),
813
+ },
814
+ },
815
+ async ({ test_id, name, reproduce, force }) => {
816
+ try {
817
+ // Resolve test by id or name.
818
+ let resolvedId = test_id;
819
+ if (!resolvedId) {
820
+ if (!name) {
821
+ return { content: [{ type: "text", text: "Provide a test_id or name." }], isError: true };
822
+ }
823
+ const data = await apiGet("/api/tests/list");
824
+ const needle = name.toLowerCase();
825
+ const matches = data.tests.filter((t) => t.name && t.name.toLowerCase().includes(needle));
826
+ if (matches.length === 0) {
827
+ return { content: [{ type: "text", text: `No test found matching "${name}". Use list_tests to see available tests.` }], isError: true };
828
+ }
829
+ if (matches.length > 1) {
830
+ const list = matches.map((t) => ` #${t.id} — ${t.name} (${t.url})`).join("\n");
831
+ return { content: [{ type: "text", text: `Multiple tests match "${name}":\n${list}\n\nRe-run heal_test with a specific test_id.` }] };
832
+ }
833
+ resolvedId = matches[0].id;
834
+ }
835
+
836
+ const result = await apiPost(`/api/tests/${resolvedId}/heal`, { mode: "diagnose", reproduce: reproduce !== false });
837
+ const { test, diagnosis, reproduction } = result;
838
+ const content = [];
839
+ const lines = [`Healing diagnosis for "${test.name}" (#${test.id})`, ` URL: ${test.url}`, ""];
840
+
841
+ // Healing disabled — respect unless forced.
842
+ if (diagnosis.reason === "healing-disabled" && !force) {
843
+ lines.push("⚠ " + REASON_EXPLANATIONS["healing-disabled"]);
844
+ if (diagnosis.healing_note) lines.push(` Note: ${diagnosis.healing_note}`);
845
+ return { content: [{ type: "text", text: lines.join("\n") }] };
846
+ }
847
+
848
+ // Not healable — explain and stop.
849
+ if (!diagnosis.healable && diagnosis.reason !== "healing-disabled") {
850
+ lines.push(`Not healable (${diagnosis.reason}).`);
851
+ if (REASON_EXPLANATIONS[diagnosis.reason]) lines.push(` ${REASON_EXPLANATIONS[diagnosis.reason]}`);
852
+ if (diagnosis.reason === "regression" && diagnosis.failure_class && diagnosis.failure_class !== "selector") {
853
+ lines.push(` The failure looks like a "${diagnosis.failure_class}" issue, not a layout/selector change, so healing won't help.`);
854
+ if (diagnosis.failing_step) lines.push(` Failing step: ${diagnosis.failing_step.name} — ${diagnosis.failing_step.error || ""}`);
855
+ }
856
+ return { content: [{ type: "text", text: lines.join("\n") }] };
857
+ }
858
+
859
+ // Healable (or forced past a disabled flag). Build the brief.
860
+ const reg = diagnosis.regression;
861
+ if (reg) {
862
+ lines.push("✓ Confirmed regression (layout change suspected):");
863
+ lines.push(` Last passed: run #${reg.lastPassRunId} at ${reg.lastPassAt}`);
864
+ lines.push(` Started failing: run #${reg.firstFailRunId} at ${reg.firstFailAt}`);
865
+ lines.push(` Consecutive failures: ${reg.failureStreak}`);
866
+ }
867
+ if (diagnosis.failing_step) {
868
+ lines.push("", `Failing step: ${diagnosis.failing_step.name}`);
869
+ if (diagnosis.failing_step.error) lines.push(` Error: ${diagnosis.failing_step.error}`);
870
+ }
871
+
872
+ if (reproduction && !reproduction.still_failing) {
873
+ lines.push("", "On a fresh re-run the test PASSED — the failure was transient. No fix needed right now.");
874
+ return { content: [{ type: "text", text: lines.join("\n") }] };
875
+ }
876
+
877
+ // Current test code
878
+ const fullTest = await apiGet(`/api/tests/${test.id}`);
879
+ lines.push("", "Current test code:", "```javascript", fullTest.code || "(no code)", "```");
880
+
881
+ // Current page structure
882
+ if (reproduction?.page_info) lines.push(renderPageStructure(reproduction.page_info));
883
+
884
+ // Guardrails + instructions for the calling agent
885
+ lines.push(
886
+ "",
887
+ "── How to heal this test ──",
888
+ "1. Propose updated code that changes ONLY the broken selector(s) to match the current page structure above.",
889
+ " • Do NOT change the test's logic, step order, assertions, waits, or navigation.",
890
+ " • Do NOT skip/remove steps or add workarounds (no JS .click(), no waitForTimeout).",
891
+ "2. Verify your new code with validate_test (code + url). Iterate until it passes.",
892
+ "3. Only after it passes, apply it with update_test (confirm with the user first).",
893
+ "",
894
+ "If this failing test is actually acceptable, or the user wants to fix it themselves, call",
895
+ "update_test healing_disabled=true (optionally with healing_note) to stop future suggestions.",
896
+ );
897
+
898
+ content.push({ type: "text", text: lines.join("\n") });
899
+
900
+ // Reproduction screenshots
901
+ if (reproduction?.screenshots) {
902
+ for (const [stepName, base64] of Object.entries(reproduction.screenshots)) {
903
+ if (base64) {
904
+ content.push({ type: "text", text: `Screenshot: ${stepName}` });
905
+ content.push({ type: "image", data: base64, mimeType: "image/png" });
906
+ }
907
+ }
908
+ }
909
+
910
+ return { content };
911
+ } catch (err) {
912
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
913
+ }
914
+ }
915
+ );
916
+
765
917
  // --- Notification channel tools ---
766
918
 
767
919
  server.registerTool(