@nyx-intelligence/val-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.
- package/README.md +3 -1
- package/dist/index.js +33 -17
- package/dist/scanner.js +59 -24
- package/dist/session.js +7 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,9 @@ does the reasoning and the fixing on your own key.
|
|
|
9
9
|
## What it catches
|
|
10
10
|
|
|
11
11
|
Passive (crawl): broken links & 404s, uncaught JS errors, console errors,
|
|
12
|
-
failed network requests (4xx/5xx), broken images, missing `<title
|
|
12
|
+
failed network requests (4xx/5xx), broken images, missing `<title>`,
|
|
13
|
+
horizontal layout overflow, clipped text, and janky load (layout shift / CLS).
|
|
14
|
+
Pass a `device` (e.g. `iPhone 14`, `Pixel 7`) to test the **mobile** rendering.
|
|
13
15
|
|
|
14
16
|
Interactive (the agent drives the browser): **dead buttons** (clicks every
|
|
15
17
|
button, flags the ones with no effect or that throw) and **broken funnels**
|
package/dist/index.js
CHANGED
|
@@ -23,13 +23,13 @@ function fmtSnapshot(els) {
|
|
|
23
23
|
}
|
|
24
24
|
// ───────────────────────── CLI (testing without an MCP client) ─────────────────────────
|
|
25
25
|
async function runCli(args) {
|
|
26
|
-
const [cmd, url, arg2] = args;
|
|
26
|
+
const [cmd, url, arg2, arg3] = args;
|
|
27
27
|
if (cmd === "scan" && url) {
|
|
28
|
-
const { findings, pagesVisited } = await scanSite(url, { maxPages: Number(arg2) || 12 });
|
|
28
|
+
const { findings, pagesVisited } = await scanSite(url, { maxPages: Number(arg2) || 12, device: arg3 || undefined });
|
|
29
29
|
console.log(formatReport(findings, pagesVisited));
|
|
30
30
|
}
|
|
31
31
|
else if (cmd === "exercise" && url) {
|
|
32
|
-
console.log(fmtAction(await session.open(url)));
|
|
32
|
+
console.log(fmtAction(await session.open(url, arg2 || undefined)));
|
|
33
33
|
const res = await session.exercise();
|
|
34
34
|
console.log(`\nexercised ${res.tested} button(s): ${res.dead.length} dead, ${res.errored.length} errored`);
|
|
35
35
|
for (const d of res.dead)
|
|
@@ -39,36 +39,52 @@ async function runCli(args) {
|
|
|
39
39
|
await session.close();
|
|
40
40
|
}
|
|
41
41
|
else {
|
|
42
|
-
console.error("Usage:\n val-mcp scan <url> [maxPages]\n val-mcp exercise <url
|
|
42
|
+
console.error("Usage:\n val-mcp scan <url> [maxPages] [device]\n val-mcp exercise <url> [device]\n val-mcp (start MCP server on stdio)");
|
|
43
43
|
process.exit(1);
|
|
44
44
|
}
|
|
45
45
|
process.exit(0);
|
|
46
46
|
}
|
|
47
47
|
// ───────────────────────── MCP server ─────────────────────────
|
|
48
48
|
async function runServer() {
|
|
49
|
+
// License gate — no active subscription, no tools.
|
|
50
|
+
const apiBase = process.env.VAL_API_BASE || "https://val.nyx-intelligence.com";
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${apiBase}/api/license/validate`, {
|
|
53
|
+
headers: { Authorization: `Bearer ${process.env.VAL_LICENSE_KEY || ""}` },
|
|
54
|
+
});
|
|
55
|
+
const lic = (await res.json());
|
|
56
|
+
if (!lic.valid) {
|
|
57
|
+
console.error("Val: no active subscription for this VAL_LICENSE_KEY. Subscribe at https://val.nyx-intelligence.com");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
console.error("Val: could not verify your license (check VAL_LICENSE_KEY and connectivity).");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
49
65
|
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
50
66
|
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
51
67
|
const { chromium } = await import("playwright");
|
|
52
68
|
const { z } = await import("zod");
|
|
53
|
-
const server = new McpServer({ name: "val", version: "0.
|
|
69
|
+
const server = new McpServer({ name: "val", version: "0.4.0" });
|
|
54
70
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
55
71
|
// ── Passive crawl ──
|
|
56
72
|
server.registerTool("val_scan", {
|
|
57
73
|
title: "Scan an app for UX bugs (crawl)",
|
|
58
|
-
description: "Crawl an app (localhost or live), following same-origin links, and report passive bugs: broken links/404s, uncaught JS errors, console errors, failed network requests, broken images, missing titles. Good first pass. Does NOT click buttons
|
|
59
|
-
inputSchema: { url: z.string(), maxPages: z.number().int().min(1).max(50).optional() },
|
|
60
|
-
}, async ({ url, maxPages }) => {
|
|
61
|
-
const { findings, pagesVisited } = await scanSite(url, { maxPages: maxPages ?? 12 });
|
|
74
|
+
description: "Crawl an app (localhost or live), following same-origin links, and report passive bugs: broken links/404s, uncaught JS errors, console errors, failed network requests, broken images, missing titles, horizontal layout overflow, clipped text, and janky load (layout shift / CLS). Pass `device` to test the MOBILE rendering (e.g. 'iPhone 14'). Good first pass. Does NOT click buttons — use val_open/val_click/val_type/val_exercise for that.",
|
|
75
|
+
inputSchema: { url: z.string(), maxPages: z.number().int().min(1).max(50).optional(), device: z.string().optional().describe("Emulate a mobile device, e.g. 'iPhone 14' or 'Pixel 7'") },
|
|
76
|
+
}, async ({ url, maxPages, device }) => {
|
|
77
|
+
const { findings, pagesVisited } = await scanSite(url, { maxPages: maxPages ?? 12, device });
|
|
62
78
|
return text(`${formatReport(findings, pagesVisited)}\n\nJSON:\n${JSON.stringify(findings)}`);
|
|
63
79
|
});
|
|
64
80
|
server.registerTool("val_check", {
|
|
65
81
|
title: "Re-check one page (verify a fix)",
|
|
66
82
|
description: "Run the passive checks on a SINGLE page. Use after fixing a bug to confirm it is gone.",
|
|
67
|
-
inputSchema: { url: z.string() },
|
|
68
|
-
}, async ({ url }) => {
|
|
83
|
+
inputSchema: { url: z.string(), device: z.string().optional() },
|
|
84
|
+
}, async ({ url, device }) => {
|
|
69
85
|
const browser = await chromium.launch({ headless: true });
|
|
70
86
|
try {
|
|
71
|
-
const { findings } = await checkPage(browser, url);
|
|
87
|
+
const { findings } = await checkPage(browser, url, { device });
|
|
72
88
|
return text(`${formatReport(findings, [url])}\n\nJSON:\n${JSON.stringify(findings)}`);
|
|
73
89
|
}
|
|
74
90
|
finally {
|
|
@@ -79,8 +95,8 @@ async function runServer() {
|
|
|
79
95
|
server.registerTool("val_open", {
|
|
80
96
|
title: "Open a page in the live session",
|
|
81
97
|
description: "Start (or reuse) the browser session and navigate to a URL. Returns the page state and any load errors. This begins an interactive session you drive with val_snapshot / val_click / val_type to walk a funnel (e.g. sign up, checkout).",
|
|
82
|
-
inputSchema: { url: z.string() },
|
|
83
|
-
}, async ({ url }) => text(fmtAction(await session.open(url))));
|
|
98
|
+
inputSchema: { url: z.string(), device: z.string().optional().describe("Emulate a mobile device, e.g. 'iPhone 14'") },
|
|
99
|
+
}, async ({ url, device }) => text(fmtAction(await session.open(url, device))));
|
|
84
100
|
server.registerTool("val_snapshot", {
|
|
85
101
|
title: "List the interactive elements on the current page",
|
|
86
102
|
description: "Return every clickable/fillable element on the current page with a [ref] you can pass to val_click / val_type, plus its kind and label. Call this to see what you can act on before clicking or typing.",
|
|
@@ -117,9 +133,9 @@ async function runServer() {
|
|
|
117
133
|
server.registerTool("val_screenshot", {
|
|
118
134
|
title: "Screenshot a page",
|
|
119
135
|
description: "Capture a PNG of a URL so you can reason about layout/visual issues.",
|
|
120
|
-
inputSchema: { url: z.string(), fullPage: z.boolean().optional() },
|
|
121
|
-
}, async ({ url, fullPage }) => {
|
|
122
|
-
const data = await screenshot(url, fullPage ?? false);
|
|
136
|
+
inputSchema: { url: z.string(), fullPage: z.boolean().optional(), device: z.string().optional() },
|
|
137
|
+
}, async ({ url, fullPage, device }) => {
|
|
138
|
+
const data = await screenshot(url, fullPage ?? false, device);
|
|
123
139
|
return { content: [{ type: "image", data, mimeType: "image/png" }] };
|
|
124
140
|
});
|
|
125
141
|
server.registerTool("val_close", {
|
package/dist/scanner.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { chromium } from "playwright";
|
|
1
|
+
import { chromium, devices } from "playwright";
|
|
2
2
|
let _seq = 0;
|
|
3
3
|
function finding(type, severity, page, detail, evidence) {
|
|
4
4
|
return { id: `f${++_seq}`, type, severity, page, detail, evidence };
|
|
5
5
|
}
|
|
6
|
-
/** Strip the hash and normalize so we don't visit the same page twice. */
|
|
7
6
|
function normalize(u) {
|
|
8
7
|
try {
|
|
9
8
|
const url = new URL(u);
|
|
@@ -14,14 +13,21 @@ function normalize(u) {
|
|
|
14
13
|
return "";
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
/** Build a context, optionally emulating a mobile device (e.g. "iPhone 14", "Pixel 7"). */
|
|
17
|
+
export async function makeContext(browser, device) {
|
|
18
|
+
if (device && devices[device])
|
|
19
|
+
return browser.newContext({ ...devices[device] });
|
|
20
|
+
if (device)
|
|
21
|
+
return browser.newContext({ ...devices["iPhone 14"] }); // unknown name → sensible mobile default
|
|
22
|
+
return browser.newContext();
|
|
23
|
+
}
|
|
24
|
+
// Registered before load so it captures the cumulative layout shift during paint.
|
|
25
|
+
const CLS_INIT = `(() => { try { window.__valCLS = 0; new PerformanceObserver((l) => { for (const e of l.getEntries()) { if (!e.hadRecentInput) window.__valCLS += e.value || 0; } }).observe({ type: "layout-shift", buffered: true }); } catch (e) {} })();`;
|
|
26
|
+
export async function checkPage(browser, url, opts = {}) {
|
|
27
|
+
const timeoutMs = opts.timeoutMs ?? 20000;
|
|
28
|
+
const ctx = await makeContext(browser, opts.device);
|
|
24
29
|
const page = await ctx.newPage();
|
|
30
|
+
await page.addInitScript(CLS_INIT);
|
|
25
31
|
const findings = [];
|
|
26
32
|
const consoleErrors = new Set();
|
|
27
33
|
const pageErrors = new Set();
|
|
@@ -30,8 +36,6 @@ export async function checkPage(browser, url, timeoutMs = 20000) {
|
|
|
30
36
|
if (m.type() !== "error")
|
|
31
37
|
return;
|
|
32
38
|
const t = m.text();
|
|
33
|
-
// Browser-generated "Failed to load resource" lines just duplicate the
|
|
34
|
-
// network-error findings, so drop them to keep the report clean.
|
|
35
39
|
if (/Failed to load resource/i.test(t))
|
|
36
40
|
return;
|
|
37
41
|
consoleErrors.add(t.slice(0, 300));
|
|
@@ -50,12 +54,12 @@ export async function checkPage(browser, url, timeoutMs = 20000) {
|
|
|
50
54
|
const status = resp?.status() ?? 0;
|
|
51
55
|
if (status >= 400)
|
|
52
56
|
findings.push(finding("page-status", "high", url, `Page returned HTTP ${status}`, String(status)));
|
|
53
|
-
|
|
54
|
-
await page.waitForTimeout(900);
|
|
57
|
+
await page.waitForTimeout(1100);
|
|
55
58
|
const title = (await page.title()).trim();
|
|
56
59
|
if (!title)
|
|
57
60
|
findings.push(finding("missing-title", "low", url, "Page has no <title>"));
|
|
58
61
|
const dom = await page.evaluate(() => {
|
|
62
|
+
const w = window;
|
|
59
63
|
const links = Array.from(document.querySelectorAll("a[href]"))
|
|
60
64
|
.map((a) => a.href)
|
|
61
65
|
.filter((h) => h.startsWith("http"));
|
|
@@ -65,12 +69,45 @@ export async function checkPage(browser, url, timeoutMs = 20000) {
|
|
|
65
69
|
return i.complete && i.naturalWidth === 0 && !!(i.currentSrc || i.src);
|
|
66
70
|
})
|
|
67
71
|
.map((img) => img.currentSrc || img.src);
|
|
68
|
-
|
|
72
|
+
// Horizontal overflow: content wider than the viewport (a classic broken layout).
|
|
73
|
+
const vw = w.innerWidth;
|
|
74
|
+
const docW = document.documentElement.scrollWidth;
|
|
75
|
+
const horizontalOverflow = docW > vw + 2 ? docW - vw : 0;
|
|
76
|
+
// Clipped text: a container hides its own overflowing text (and not on purpose).
|
|
77
|
+
const clipped = [];
|
|
78
|
+
const nodes = Array.from(document.querySelectorAll("*")).slice(0, 4000);
|
|
79
|
+
for (const el of nodes) {
|
|
80
|
+
const e = el;
|
|
81
|
+
if (e.children.length !== 0)
|
|
82
|
+
continue; // leaf text only
|
|
83
|
+
const text = (e.textContent || "").trim();
|
|
84
|
+
if (text.length < 10)
|
|
85
|
+
continue;
|
|
86
|
+
const cs = getComputedStyle(e);
|
|
87
|
+
const hidden = cs.overflow === "hidden" || cs.overflowX === "hidden" || cs.overflowY === "hidden";
|
|
88
|
+
if (!hidden)
|
|
89
|
+
continue;
|
|
90
|
+
if (cs.textOverflow === "ellipsis" || cs.getPropertyValue("-webkit-line-clamp") !== "none")
|
|
91
|
+
continue; // intentional
|
|
92
|
+
if (e.scrollWidth > e.clientWidth + 4 || e.scrollHeight > e.clientHeight + 4) {
|
|
93
|
+
const r = e.getBoundingClientRect();
|
|
94
|
+
if (r.width > 0 && r.height > 0)
|
|
95
|
+
clipped.push(text.slice(0, 50));
|
|
96
|
+
}
|
|
97
|
+
if (clipped.length >= 8)
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
return { links, brokenImgs, horizontalOverflow, clipped, cls: w.__valCLS || 0 };
|
|
69
101
|
});
|
|
70
102
|
links = dom.links;
|
|
71
|
-
for (const src of new Set(dom.brokenImgs))
|
|
103
|
+
for (const src of new Set(dom.brokenImgs))
|
|
72
104
|
findings.push(finding("broken-image", "medium", url, "Image failed to load", src));
|
|
73
|
-
|
|
105
|
+
if (dom.horizontalOverflow > 0)
|
|
106
|
+
findings.push(finding("layout-overflow", "medium", url, `Page scrolls horizontally — content ${Math.round(dom.horizontalOverflow)}px wider than the viewport`));
|
|
107
|
+
for (const t of new Set(dom.clipped))
|
|
108
|
+
findings.push(finding("clipped-text", "low", url, "Text appears clipped by its container", t));
|
|
109
|
+
if (dom.cls > 0.25)
|
|
110
|
+
findings.push(finding("layout-shift", "medium", url, `Janky load: cumulative layout shift ${dom.cls.toFixed(2)} (target < 0.1)`));
|
|
74
111
|
}
|
|
75
112
|
catch (e) {
|
|
76
113
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -82,16 +119,14 @@ export async function checkPage(browser, url, timeoutMs = 20000) {
|
|
|
82
119
|
findings.push(finding("console-error", "medium", url, `Console error: ${t}`));
|
|
83
120
|
for (const [u, s] of netErrors) {
|
|
84
121
|
if (u === mainUrl)
|
|
85
|
-
continue;
|
|
122
|
+
continue;
|
|
86
123
|
findings.push(finding("network-error", s >= 500 ? "high" : "medium", url, `Resource returned HTTP ${s}`, u));
|
|
87
124
|
}
|
|
88
125
|
await ctx.close();
|
|
89
126
|
return { findings, links };
|
|
90
127
|
}
|
|
91
|
-
/** BFS-crawl same-origin pages from startUrl and check each one. */
|
|
92
128
|
export async function scanSite(startUrl, opts = {}) {
|
|
93
129
|
const maxPages = opts.maxPages ?? 12;
|
|
94
|
-
const timeoutMs = opts.timeoutMs ?? 20000;
|
|
95
130
|
const start = normalize(startUrl);
|
|
96
131
|
if (!start)
|
|
97
132
|
throw new Error(`Invalid URL: ${startUrl}`);
|
|
@@ -108,7 +143,7 @@ export async function scanSite(startUrl, opts = {}) {
|
|
|
108
143
|
if (visited.has(url))
|
|
109
144
|
continue;
|
|
110
145
|
visited.add(url);
|
|
111
|
-
const res = await checkPage(browser, url, timeoutMs);
|
|
146
|
+
const res = await checkPage(browser, url, { timeoutMs: opts.timeoutMs, device: opts.device });
|
|
112
147
|
findings.push(...res.findings);
|
|
113
148
|
pagesVisited.push(url);
|
|
114
149
|
for (const link of res.links) {
|
|
@@ -122,7 +157,7 @@ export async function scanSite(startUrl, opts = {}) {
|
|
|
122
157
|
}
|
|
123
158
|
}
|
|
124
159
|
catch {
|
|
125
|
-
/* ignore
|
|
160
|
+
/* ignore */
|
|
126
161
|
}
|
|
127
162
|
}
|
|
128
163
|
}
|
|
@@ -132,11 +167,11 @@ export async function scanSite(startUrl, opts = {}) {
|
|
|
132
167
|
}
|
|
133
168
|
return { findings, pagesVisited };
|
|
134
169
|
}
|
|
135
|
-
|
|
136
|
-
export async function screenshot(url, fullPage = false, timeoutMs = 20000) {
|
|
170
|
+
export async function screenshot(url, fullPage = false, device, timeoutMs = 20000) {
|
|
137
171
|
const browser = await chromium.launch({ headless: true });
|
|
138
172
|
try {
|
|
139
|
-
const
|
|
173
|
+
const ctx = await makeContext(browser, device);
|
|
174
|
+
const page = await ctx.newPage();
|
|
140
175
|
await page.goto(url, { waitUntil: "load", timeout: timeoutMs });
|
|
141
176
|
await page.waitForTimeout(700);
|
|
142
177
|
const buf = await page.screenshot({ fullPage, type: "png" });
|
package/dist/session.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { chromium } from "playwright";
|
|
1
|
+
import { chromium, devices } from "playwright";
|
|
2
2
|
function dedupe(a) {
|
|
3
3
|
return Array.from(new Set(a));
|
|
4
4
|
}
|
|
5
5
|
class ValSession {
|
|
6
6
|
browser;
|
|
7
7
|
page;
|
|
8
|
+
device;
|
|
8
9
|
consoleErrors = [];
|
|
9
10
|
pageErrors = [];
|
|
10
11
|
netErrors = [];
|
|
@@ -12,7 +13,7 @@ class ValSession {
|
|
|
12
13
|
if (this.browser && this.page)
|
|
13
14
|
return this.page;
|
|
14
15
|
this.browser = await chromium.launch({ headless: true });
|
|
15
|
-
const ctx = await this.browser.newContext();
|
|
16
|
+
const ctx = await this.browser.newContext(this.device && devices[this.device] ? { ...devices[this.device] } : this.device ? { ...devices["iPhone 14"] } : {});
|
|
16
17
|
this.page = await ctx.newPage();
|
|
17
18
|
this.page.on("console", (m) => {
|
|
18
19
|
if (m.type() === "error" && !/Failed to load resource/i.test(m.text())) {
|
|
@@ -50,7 +51,10 @@ class ValSession {
|
|
|
50
51
|
return byRef.first();
|
|
51
52
|
return page.getByText(refOrText, { exact: false }).first();
|
|
52
53
|
}
|
|
53
|
-
async open(url) {
|
|
54
|
+
async open(url, device) {
|
|
55
|
+
if (device !== this.device && this.browser)
|
|
56
|
+
await this.close();
|
|
57
|
+
this.device = device;
|
|
54
58
|
const page = await this.ensure();
|
|
55
59
|
this.drain();
|
|
56
60
|
let note;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyx-intelligence/val-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Val — a 100% MCP QA agent for vibecoders. Drives a real browser to catch UX bugs (broken links, 404s, console errors, broken images) so your coding agent can fix them.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|