@nyx-intelligence/val-mcp 0.1.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 ADDED
@@ -0,0 +1,47 @@
1
+ # @nyx-intelligence/val-mcp
2
+
3
+ Val is a **100% MCP QA agent** for vibecoders. It drives a real browser
4
+ (Playwright + Chromium) to catch UX/functional bugs in your app, and exposes
5
+ them as tools your coding assistant can call. Val itself spends **no AI tokens**:
6
+ detection is deterministic and local; your agent (Claude Code, Cursor, Windsurf)
7
+ does the reasoning and the fixing on your own key.
8
+
9
+ ## What it catches (v0)
10
+
11
+ - Broken links & 404s (same-origin pages that return HTTP ≥ 400)
12
+ - Uncaught JS errors (`pageerror`)
13
+ - Console errors
14
+ - Failed network requests (4xx/5xx resources and API calls)
15
+ - Broken images (failed to load)
16
+ - Missing `<title>`
17
+
18
+ UX/functional only — **not** security/pentest.
19
+
20
+ ## Install (MCP)
21
+
22
+ Add to your MCP client config (e.g. Claude Code / Cursor):
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "val": { "command": "npx", "args": ["-y", "@nyx-intelligence/val-mcp"] }
28
+ }
29
+ }
30
+ ```
31
+
32
+ First run downloads Chromium if needed (`npx playwright install chromium`).
33
+
34
+ ## Tools
35
+
36
+ - `val_scan({ url, maxPages? })` — crawl an app and report all findings. Run first.
37
+ - `val_check({ url })` — re-check one page after a fix (the "verify" step).
38
+ - `val_screenshot({ url, fullPage? })` — PNG of a page for visual reasoning.
39
+
40
+ The detect → fix → verify loop: your agent calls `val_scan`, fixes the code,
41
+ then calls `val_check` on the affected page, repeating until everything is green.
42
+
43
+ ## Test without an MCP client
44
+
45
+ ```bash
46
+ val-mcp scan http://localhost:3000 20
47
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { scanSite, checkPage, screenshot } from "./scanner.js";
3
+ import { formatReport } from "./report.js";
4
+ /** CLI mode: `val-mcp scan <url> [maxPages]` — for testing without an MCP client. */
5
+ async function runCli(args) {
6
+ const [cmd, url, maxPagesArg] = args;
7
+ if (cmd === "scan" && url) {
8
+ const maxPages = Number(maxPagesArg) || 12;
9
+ const { findings, pagesVisited } = await scanSite(url, { maxPages });
10
+ console.log(formatReport(findings, pagesVisited));
11
+ process.exit(0);
12
+ }
13
+ console.error("Usage:\n val-mcp scan <url> [maxPages] run a scan and print the report\n val-mcp start the MCP server on stdio");
14
+ process.exit(1);
15
+ }
16
+ /** Default mode: MCP server over stdio. */
17
+ async function runServer() {
18
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
19
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
20
+ const { chromium } = await import("playwright");
21
+ const { z } = await import("zod");
22
+ const server = new McpServer({ name: "val", version: "0.1.0" });
23
+ server.registerTool("val_scan", {
24
+ title: "Scan an app for UX bugs",
25
+ description: "Crawl an app (localhost or live URL), following same-origin links, and report UX/functional bugs: broken links and 404s, uncaught JS errors, console errors, failed network requests, broken images, missing titles. Returns a prioritized list with the page each bug is on. Run this first.",
26
+ inputSchema: {
27
+ url: z.string().describe("Start URL, e.g. http://localhost:3000"),
28
+ maxPages: z.number().int().min(1).max(50).optional().describe("Max pages to crawl (default 12)"),
29
+ },
30
+ }, async ({ url, maxPages }) => {
31
+ const { findings, pagesVisited } = await scanSite(url, { maxPages: maxPages ?? 12 });
32
+ return {
33
+ content: [
34
+ { type: "text", text: `${formatReport(findings, pagesVisited)}\n\nJSON:\n${JSON.stringify(findings)}` },
35
+ ],
36
+ };
37
+ });
38
+ server.registerTool("val_check", {
39
+ title: "Re-check one page (verify a fix)",
40
+ description: "Run the UX checks on a SINGLE page only. Use this after fixing a bug to confirm it is gone and nothing new broke on that page. This is the 'verify' half of the detect-fix-verify loop.",
41
+ inputSchema: { url: z.string().describe("The page URL to re-check") },
42
+ }, async ({ url }) => {
43
+ const browser = await chromium.launch({ headless: true });
44
+ try {
45
+ const { findings } = await checkPage(browser, url);
46
+ return {
47
+ content: [
48
+ { type: "text", text: `${formatReport(findings, [url])}\n\nJSON:\n${JSON.stringify(findings)}` },
49
+ ],
50
+ };
51
+ }
52
+ finally {
53
+ await browser.close();
54
+ }
55
+ });
56
+ server.registerTool("val_screenshot", {
57
+ title: "Screenshot a page",
58
+ description: "Capture a PNG screenshot of a page so you can reason about layout or visual issues.",
59
+ inputSchema: {
60
+ url: z.string().describe("The page URL to screenshot"),
61
+ fullPage: z.boolean().optional().describe("Capture the full scrollable page (default false)"),
62
+ },
63
+ }, async ({ url, fullPage }) => {
64
+ const data = await screenshot(url, fullPage ?? false);
65
+ return { content: [{ type: "image", data, mimeType: "image/png" }] };
66
+ });
67
+ const transport = new StdioServerTransport();
68
+ await server.connect(transport);
69
+ // stdout is the MCP channel; logs go to stderr.
70
+ console.error("val-mcp server running on stdio");
71
+ }
72
+ const args = process.argv.slice(2);
73
+ if (args[0] === "scan") {
74
+ runCli(args).catch((e) => {
75
+ console.error(e);
76
+ process.exit(1);
77
+ });
78
+ }
79
+ else {
80
+ runServer().catch((e) => {
81
+ console.error(e);
82
+ process.exit(1);
83
+ });
84
+ }
package/dist/report.js ADDED
@@ -0,0 +1,26 @@
1
+ const RANK = { high: 0, medium: 1, low: 2 };
2
+ /** Render findings as a readable, agent-friendly report. */
3
+ export function formatReport(findings, pagesVisited) {
4
+ const counts = { high: 0, medium: 0, low: 0 };
5
+ for (const f of findings)
6
+ counts[f.severity]++;
7
+ const lines = [];
8
+ lines.push(`Val scan: ${pagesVisited.length} page(s) crawled, ${findings.length} finding(s) — ` +
9
+ `${counts.high} high, ${counts.medium} medium, ${counts.low} low.`);
10
+ if (findings.length === 0) {
11
+ lines.push("All green. No UX bugs detected on the crawled pages.");
12
+ }
13
+ else {
14
+ lines.push("");
15
+ const sorted = [...findings].sort((a, b) => RANK[a.severity] - RANK[b.severity]);
16
+ for (const f of sorted) {
17
+ lines.push(`[${f.severity.toUpperCase()}] ${f.type} — ${f.page}`);
18
+ lines.push(` ${f.detail}${f.evidence ? ` (${f.evidence})` : ""}`);
19
+ }
20
+ }
21
+ lines.push("");
22
+ lines.push("Pages crawled:");
23
+ for (const p of pagesVisited)
24
+ lines.push(` - ${p}`);
25
+ return lines.join("\n");
26
+ }
@@ -0,0 +1,148 @@
1
+ import { chromium } from "playwright";
2
+ let _seq = 0;
3
+ function finding(type, severity, page, detail, evidence) {
4
+ return { id: `f${++_seq}`, type, severity, page, detail, evidence };
5
+ }
6
+ /** Strip the hash and normalize so we don't visit the same page twice. */
7
+ function normalize(u) {
8
+ try {
9
+ const url = new URL(u);
10
+ url.hash = "";
11
+ return url.toString();
12
+ }
13
+ catch {
14
+ return "";
15
+ }
16
+ }
17
+ /**
18
+ * Load one page in a fresh context, capture browser-level signals
19
+ * (HTTP status, uncaught errors, console errors, failed responses,
20
+ * broken images, missing title) and the outgoing links.
21
+ */
22
+ export async function checkPage(browser, url, timeoutMs = 20000) {
23
+ const ctx = await browser.newContext();
24
+ const page = await ctx.newPage();
25
+ const findings = [];
26
+ const consoleErrors = new Set();
27
+ const pageErrors = new Set();
28
+ const netErrors = new Map();
29
+ page.on("console", (m) => {
30
+ if (m.type() !== "error")
31
+ return;
32
+ 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
+ if (/Failed to load resource/i.test(t))
36
+ return;
37
+ consoleErrors.add(t.slice(0, 300));
38
+ });
39
+ page.on("pageerror", (e) => pageErrors.add((e.message || String(e)).slice(0, 300)));
40
+ page.on("response", (r) => {
41
+ const s = r.status();
42
+ if (s >= 400)
43
+ netErrors.set(r.url(), s);
44
+ });
45
+ let links = [];
46
+ let mainUrl = url;
47
+ try {
48
+ const resp = await page.goto(url, { waitUntil: "load", timeout: timeoutMs });
49
+ mainUrl = resp?.url() ?? url;
50
+ const status = resp?.status() ?? 0;
51
+ if (status >= 400)
52
+ findings.push(finding("page-status", "high", url, `Page returned HTTP ${status}`, String(status)));
53
+ // let late JS / lazy images settle
54
+ await page.waitForTimeout(900);
55
+ const title = (await page.title()).trim();
56
+ if (!title)
57
+ findings.push(finding("missing-title", "low", url, "Page has no <title>"));
58
+ const dom = await page.evaluate(() => {
59
+ const links = Array.from(document.querySelectorAll("a[href]"))
60
+ .map((a) => a.href)
61
+ .filter((h) => h.startsWith("http"));
62
+ const brokenImgs = Array.from(document.querySelectorAll("img"))
63
+ .filter((img) => {
64
+ const i = img;
65
+ return i.complete && i.naturalWidth === 0 && !!(i.currentSrc || i.src);
66
+ })
67
+ .map((img) => img.currentSrc || img.src);
68
+ return { links, brokenImgs };
69
+ });
70
+ links = dom.links;
71
+ for (const src of new Set(dom.brokenImgs)) {
72
+ findings.push(finding("broken-image", "medium", url, "Image failed to load", src));
73
+ }
74
+ }
75
+ catch (e) {
76
+ const msg = e instanceof Error ? e.message : String(e);
77
+ findings.push(finding("page-load", "high", url, `Page failed to load: ${msg}`));
78
+ }
79
+ for (const t of pageErrors)
80
+ findings.push(finding("page-error", "high", url, `Uncaught JS error: ${t}`));
81
+ for (const t of consoleErrors)
82
+ findings.push(finding("console-error", "medium", url, `Console error: ${t}`));
83
+ for (const [u, s] of netErrors) {
84
+ if (u === mainUrl)
85
+ continue; // the main document status is already reported as page-status
86
+ findings.push(finding("network-error", s >= 500 ? "high" : "medium", url, `Resource returned HTTP ${s}`, u));
87
+ }
88
+ await ctx.close();
89
+ return { findings, links };
90
+ }
91
+ /** BFS-crawl same-origin pages from startUrl and check each one. */
92
+ export async function scanSite(startUrl, opts = {}) {
93
+ const maxPages = opts.maxPages ?? 12;
94
+ const timeoutMs = opts.timeoutMs ?? 20000;
95
+ const start = normalize(startUrl);
96
+ if (!start)
97
+ throw new Error(`Invalid URL: ${startUrl}`);
98
+ const origin = new URL(start).origin;
99
+ const browser = await chromium.launch({ headless: true });
100
+ const findings = [];
101
+ const visited = new Set();
102
+ const queued = new Set([start]);
103
+ const queue = [start];
104
+ const pagesVisited = [];
105
+ try {
106
+ while (queue.length > 0 && pagesVisited.length < maxPages) {
107
+ const url = queue.shift();
108
+ if (visited.has(url))
109
+ continue;
110
+ visited.add(url);
111
+ const res = await checkPage(browser, url, timeoutMs);
112
+ findings.push(...res.findings);
113
+ pagesVisited.push(url);
114
+ for (const link of res.links) {
115
+ const n = normalize(link);
116
+ if (!n || queued.has(n))
117
+ continue;
118
+ try {
119
+ if (new URL(n).origin === origin) {
120
+ queued.add(n);
121
+ queue.push(n);
122
+ }
123
+ }
124
+ catch {
125
+ /* ignore unparseable links */
126
+ }
127
+ }
128
+ }
129
+ }
130
+ finally {
131
+ await browser.close();
132
+ }
133
+ return { findings, pagesVisited };
134
+ }
135
+ /** Screenshot a single page, return base64 PNG. */
136
+ export async function screenshot(url, fullPage = false, timeoutMs = 20000) {
137
+ const browser = await chromium.launch({ headless: true });
138
+ try {
139
+ const page = await browser.newPage();
140
+ await page.goto(url, { waitUntil: "load", timeout: timeoutMs });
141
+ await page.waitForTimeout(700);
142
+ const buf = await page.screenshot({ fullPage, type: "png" });
143
+ return buf.toString("base64");
144
+ }
145
+ finally {
146
+ await browser.close();
147
+ }
148
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@nyx-intelligence/val-mcp",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "val-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsc --watch",
16
+ "scan": "node dist/index.js scan",
17
+ "type-check": "tsc --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.12.0",
21
+ "playwright": "^1.49.0",
22
+ "zod": "^3.23.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^20",
26
+ "typescript": "^5"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ }
31
+ }